diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6ed9a37..d685140 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,8 @@ product overview.
## Unreleased
+- Gateway Settings now lets admins map short names to an engine and model. In Slack, select the bot mention and type `:astra` after Slack's inserted space (`@agent :astra`) to pin that model to a thread, with an optional task after the name. Mappings can be updated as models change.
+
- Conversations can filter by Google Chat, Slack or Teams alongside search and Channels / DMs.
Teams channel names now use `#team-channel`, resolving missing names through the bot directory
on incoming messages while preserving conversation IDs, work folders and settings.
diff --git a/FEATURES.md b/FEATURES.md
index 692b253..06af33c 100644
--- a/FEATURES.md
+++ b/FEATURES.md
@@ -1,5 +1,9 @@
# ChannelGate — Features
+## Gateway model shortcuts
+
+Admins can add gateway-wide model shortcuts in Settings → Access & security → Model shortcuts. Each name selects an engine and model. In Slack, `@agent :astra task` pins that engine and model to the current thread and runs the task; `@agent :astra` switches the thread without starting an agent turn. Slack's automatically inserted space after the mention is accepted. Changing a mapping affects later shortcut selections, while threads already pinned keep their selected model. Channel runtime-change access and dedicated Codex-login restrictions apply.
+
## Public documentation website
- `documentation/` builds a static Astro/Starlight documentation section for
diff --git a/TEST-PLAN.md b/TEST-PLAN.md
index 1d177fa..83acd63 100644
--- a/TEST-PLAN.md
+++ b/TEST-PLAN.md
@@ -1,5 +1,16 @@
# ChannelGate — Test Plan
+## Model shortcuts acceptance (2026-10-06)
+
+- [x] Automated: `test/model-shortcuts.test.js` checks parsing, saving, repointing, clearing and refusing invalid mappings.
+- [x] Browser: `test/model-shortcuts-browser.test.js` ran in Chromium against a disposable admin API and store. Added a Codex shortcut from the current catalog, saved and reloaded it, repointed its model and engine, and removed it; no page errors.
+- [x] Pipeline: `test/model-shortcut-pipeline.test.js` ran the real Slack pipeline with spawned Claude/Codex fixture CLIs. Verified Slack's inserted space, model flags on new/resumed turns, a Codex→Claude switch, stable pins after repointing a shortcut, stripped prefixes on runtime-card retries, and refusal before pin changes for runtime permissions, disabled engines and a dedicated Codex login. These are integration fixtures, not live provider acceptance.
+- Integration check: static checks passed (781 JavaScript files). Full suite: 3,211 passed, 30 skipped, one existing failure in `test/readme.test.js` asserting a single non-partner website link; the new documentation link makes the count two. `README.md` and that test are identical to the beta base. Focused shortcut tests and the explicit Chromium run passed.
+- [ ] Live: In gateway Settings, add `astra` → Codex / a currently offered model and `opus` → Claude / a currently offered model. Save and reload; verify both mappings remain. Repoint `astra` to another offered Codex model, save and reload.
+- [ ] Live: In a Slack channel with runtime changes allowed for the author, select the bot from Slack's mention picker and send `@agent :astra summarize this thread` as a new root message, retaining Slack's inserted space. Verify the reply uses the selected Codex model and the thread settings show the pin. Reply `@agent :opus` in the same thread; verify the switch acknowledgement and the next ordinary request uses the selected Claude model with earlier thread context available.
+- [ ] Live: In a channel restricted to admin runtime changes, verify a non-admin's `@agent :astra task` is refused and leaves the thread runtime unchanged. Verify an unknown name is refused. In a channel with its own Codex login, verify a Claude shortcut is refused.
+- [ ] Live: Run the reverse engine path with `@agent :opus task` as a new root message and `@agent :astra` within that thread, confirming the Codex answer retains earlier context. Check both engines' recorded model and effort (the shortcut clears a previous effort pin).
+
## Complete functionality handbook (2026-10-06)
- [x] Inventory reconciliation: all 45 functional areas map to detailed guides; 106 feature
diff --git a/public/app.js b/public/app.js
index 71f5d08..172bb54 100644
--- a/public/app.js
+++ b/public/app.js
@@ -183,6 +183,60 @@ function syncModelOptions({ engineSelect, modelSelect, value, engine, blankLabel
modelSelect.value = options.some(([v]) => v === current) ? current : "";
}
+function addModelShortcutRow(name = "", target = {}) {
+ const box = document.getElementById("model-shortcuts-editor");
+ const row = document.createElement("div");
+ row.className = "model-shortcut-row";
+ row.style.cssText = "display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:8px 0";
+ row.innerHTML = `
+
+
+ `;
+ const engineSelect = row.querySelector(".shortcut-engine");
+ const modelSelect = row.querySelector(".shortcut-model");
+ engineSelect.innerHTML = engineOptionsHtml();
+ if (target.engine && ![...engineSelect.options].some((option) => option.value === target.engine)) {
+ const option = document.createElement("option");
+ option.value = target.engine;
+ option.textContent = `${target.engine} (disabled)`;
+ engineSelect.add(option);
+ }
+ engineSelect.value = target.engine || engineSelect.options[0]?.value || "claude";
+ const fillModels = (value = "") => {
+ const engine = engineSelect.value;
+ const manifestModels = ENGINE_MANIFESTS.find((m) => m.id === engine)?.models || [];
+ const options = manifestModels.length ? manifestModels.map((m) => [m.value, m.label || m.value]) : [...(MODEL_OPTIONS[engine] || [])];
+ if (value && !options.some(([id]) => id === value)) options.push([value, value]);
+ modelSelect.innerHTML = options.map(([id, label]) => ``).join("");
+ modelSelect.value = value && options.some(([id]) => id === value) ? value : (options[0]?.[0] || "");
+ };
+ row.querySelector(".shortcut-name").value = name;
+ fillModels(target.model);
+ engineSelect.addEventListener("change", () => fillModels());
+ row.querySelector(".shortcut-remove").addEventListener("click", () => { row.remove(); markSettingsDirty(); });
+ box.append(row);
+}
+
+function paintModelShortcuts(shortcuts) {
+ const box = document.getElementById("model-shortcuts-editor");
+ box.replaceChildren();
+ for (const [name, target] of Object.entries(shortcuts || {})) addModelShortcutRow(name, target);
+ document.getElementById("add-model-shortcut").disabled = false;
+}
+
+function collectModelShortcuts() {
+ const shortcuts = {};
+ for (const row of document.querySelectorAll("#model-shortcuts-editor .model-shortcut-row")) {
+ const name = row.querySelector(".shortcut-name").value.trim().toLowerCase();
+ if (!/^[a-z][a-z0-9_-]{0,23}$/.test(name)) throw new Error(`Invalid model shortcut name: ${name || "(blank)"}`);
+ if (Object.hasOwn(shortcuts, name)) throw new Error(`Duplicate model shortcut: ${name}`);
+ shortcuts[name] = { engine: row.querySelector(".shortcut-engine").value, model: row.querySelector(".shortcut-model").value };
+ }
+ return shortcuts;
+}
+
+document.getElementById("add-model-shortcut").addEventListener("click", () => { addModelShortcutRow(); markSettingsDirty(); });
+
function checkboxList(container, items, selected, valueKey = "value", labelKey = "label") {
container.innerHTML = "";
if (!items.length) {
@@ -3615,6 +3669,7 @@ function readSettingsForm() {
defaultCodexModel: document.getElementById("set-default-codex-model").value,
...collectQwenProviders(),
modelChangeAccess: document.getElementById("set-model-change-access").value,
+ modelShortcuts: collectModelShortcuts(),
engineEnabled: { ...ENGINE_ENABLED },
engineFallback: document.getElementById("set-engine-fallback").checked,
engineFallbackMode: document.getElementById("set-engine-fallback-mode").value,
@@ -3748,6 +3803,7 @@ function paintSettings(s) {
syncModelOptions({ modelSelect: document.getElementById("set-default-claude-model"), engine: "claude", value: s.defaultClaudeModel || "", blankLabel: "CLI default" });
syncModelOptions({ modelSelect: document.getElementById("set-default-codex-model"), engine: "codex", value: s.defaultCodexModel || "", blankLabel: "CLI default" });
document.getElementById("set-model-change-access").value = s.modelChangeAccess || "admins";
+ paintModelShortcuts(s.modelShortcuts);
document.getElementById("set-engine-fallback").checked = s.engineFallback !== false;
document.getElementById("set-engine-fallback-mode").value = s.engineFallbackMode || "auto";
document.getElementById("set-show-message-cost").checked = s.showMessageCost !== false;
@@ -4156,7 +4212,9 @@ function bindSettings() {
saved.textContent = "saving…";
// Only the fields this admin actually changed. Everything else is left to whatever the daemon
// holds now — the whole point: an unrelated save must not revert another writer.
- const form = readSettingsForm();
+ let form;
+ try { form = readSettingsForm(); }
+ catch (e) { saved.textContent = "✗ " + e.message; return; }
const patch = diffSettingsPayload(SETTINGS_BASELINE || {}, form);
const newPw = form.adminPassword || "";
// Only (re)connect Slack when a Slack token was actually changed in this save — a normal
diff --git a/public/index.html b/public/index.html
index 991c7f2..36ae4b1 100644
--- a/public/index.html
+++ b/public/index.html
@@ -917,6 +917,12 @@
Runtime changes
Controls both channel-wide and thread-only /model changes. DM users can always customize their own runtime.
+
+
Model shortcuts
+
Mention the bot, then type a colon and a name after Slack's inserted space, for example @agent :astra review this. The selected engine and model are pinned to that thread. Use the same form without a task to switch an existing thread. Names apply gateway-wide; channel runtime permissions still apply.
+
+
+
Container runtime
Every channel's engine processes run inside its own long-lived Linux container — separate HOME, separate CLI logins, its own process namespace, and none of the host's files reachable beyond the channel's own folders. This is the only runtime: the daemon needs podman or docker on this machine plus a built image (npm run build:image). Admin-mode channels run in their container too; the permission bypass applies inside it, never on the host.
diff --git a/src/config/model-shortcuts.js b/src/config/model-shortcuts.js
new file mode 100644
index 0000000..66cd176
--- /dev/null
+++ b/src/config/model-shortcuts.js
@@ -0,0 +1,19 @@
+// Gateway-wide names for explicit, thread-scoped Slack model choices.
+export const MODEL_SHORTCUT_NAME_RE = /^[a-z][a-z0-9_-]{0,23}$/;
+export const MAX_MODEL_SHORTCUTS = 32;
+
+export function modelShortcutsFromSettings(settings) {
+ const raw = settings?.modelShortcuts;
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
+ return Object.fromEntries(Object.entries(raw).filter(([name, target]) =>
+ MODEL_SHORTCUT_NAME_RE.test(name) && target && typeof target === "object" &&
+ typeof target.engine === "string" && typeof target.model === "string"
+ ));
+}
+
+// Slack gives the bot mention as <@ID>; stripMentions leaves the colon at the start.
+// Require a separating space before the task, so ":astraight" never selects ":astra".
+export function parseModelShortcut(text) {
+ const match = /^:\s*([a-z][a-z0-9_-]{0,23})(?=\s|$)\s*([\s\S]*)$/i.exec(String(text || "").trim());
+ return match ? { name: match[1].toLowerCase(), task: match[2].trim() } : null;
+}
diff --git a/src/config/settings.js b/src/config/settings.js
index 019011b..9f32183 100644
--- a/src/config/settings.js
+++ b/src/config/settings.js
@@ -14,6 +14,7 @@ import { QWEN_PROVIDERS, qwenProvider } from "../engines/qwen.js";
// The default container image ref lives with the image module (a dependency-free leaf) so the
// transactional updater can name the same image without importing this file's database layer.
import { CONTAINER_DEFAULT_IMAGE } from "../runtimes/container/image.js";
+import { modelShortcutsFromSettings } from "./model-shortcuts.js";
// settings.json key → environment variable it feeds.
const ENV_MAP = {
@@ -308,6 +309,10 @@ export function getDefaultModel(engine) {
return typeof v === "string" ? v.trim() : "";
}
+export function getModelShortcuts() {
+ return modelShortcutsFromSettings(getSettings());
+}
+
// Who may use Slack's /model wizard in a CHANNEL (both channel-wide and thread-scoped picks).
// Everyone reaching this gate has already passed the channel's normal authorization policy, so
// "users" means every authorized channel user (approved members plus explicit guest grants).
@@ -1032,6 +1037,7 @@ export function settingsForApi() {
// instead of carrying a hard-coded copy of the table.
qwenProviders: qwenProviderSettings(),
modelChangeAccess: getModelChangeAccess(),
+ modelShortcuts: getModelShortcuts(),
engineEnabled: getEngineEnabledMap(),
engineFallback: getEngineFallback(),
engineFallbackMode: getEngineFallbackMode(),
diff --git a/src/slack/help.js b/src/slack/help.js
index baea5b6..950fb0d 100644
--- a/src/slack/help.js
+++ b/src/slack/help.js
@@ -14,7 +14,7 @@ export const HELP_TEXT =
"• *Reminders and schedules:* ask naturally, for example `remind me in 2 hours to call the client` or `every Monday at 09:00 summarize new messages`. Reminders only post a nudge; scheduled tasks wake the agent to do work. Ask `list schedules` or `delete schedule ` to manage them.\n" +
"• *Long-running work:* ask me to run it in the background. In an Auto/Full-access channel, the gateway keeps the job alive after the current turn and reports back in this thread when it finishes.\n" +
"• *Direct host work:* an organization admin can enable `/sudo` for one thread. Its admin messages run directly as the gateway daemon OS user, outside the channel container; non-admin messages to that thread are rejected. Use `/sudo off` to restore the normal container.\n" +
- "• *Useful checks:* `/status` shows active runs, background jobs, and schedules; `/pending` shows threads waiting on your decision; `/model` changes Claude/Codex, model, and effort; `/mode` shows the channel's tool-access mode.\n\n" +
+ "• *Useful checks:* `/status` shows active runs, background jobs, and schedules; `/pending` shows threads waiting on your decision; `/model` changes Claude/Codex, model, and effort; `@agent :shortcut task` picks a configured model for this thread; `/mode` shows the channel's tool-access mode.\n\n" +
"*Commands* (this thread/channel)\n" +
"• `/menu` — show only the Files, Variables, and Settings buttons; use `@agent /menu` inside a channel thread\n" +
"• `/help` — show this guide\n" +
diff --git a/src/slack/message-pipeline.js b/src/slack/message-pipeline.js
index 2914906..9b2877b 100644
--- a/src/slack/message-pipeline.js
+++ b/src/slack/message-pipeline.js
@@ -39,7 +39,8 @@ import { noteBotReply, noteUserActivity } from "../gateway/nudges.js";
import { applyLoopWakeup, stopLoops, stopThreadLoops } from "../gateway/loops.js";
import { buildPendingReportForUser } from "../gateway/followups.js";
-import { resolveSlackConfig, getProgressView, getContextWindow, getTrustedBotApps, getDefaultChannelAccess, applyChannelTemplate, getDefaultNudges, getSlackAdminUserToken, canChangeChannelRuntime, getWhisperEnabled, getEngineFallbackMode } from "../config/settings.js";
+import { resolveSlackConfig, getProgressView, getContextWindow, getTrustedBotApps, getDefaultChannelAccess, applyChannelTemplate, getDefaultNudges, getSlackAdminUserToken, canChangeChannelRuntime, getWhisperEnabled, getEngineFallbackMode, getModelShortcuts, isEngineEnabled } from "../config/settings.js";
+import { parseModelShortcut } from "../config/model-shortcuts.js";
import { mdToMrkdwn, resolveMentions } from "./format.js";
import { answerImageBlocks, shareAnswerImageFiles } from "./images.js";
import { appendSlackTables, extractSlackTables, formatSlackTables } from "./block-content.js";
@@ -1082,6 +1083,43 @@ export async function processMessageEvent(event, client, { botUserId = "", teamI
// stamp), and the session-stamp comparison after the directive blocks (covers switches made
// out-of-band — the /model wizard, the admin UI, a flipped gateway default).
let engineSwitched = false;
+ // A colon immediately after the bot mention selects a configured model shortcut. The
+ // setting is read for each message, so admins can repoint names without restarting Slack.
+ // Persist the resolved target on this thread; later edits to the shortcut affect new picks.
+ const shortcut = !syntheticFork ? parseModelShortcut(prompt) : null;
+ // A retry/switch card already selected the runtime. Keep its choice, but remove the
+ // original shortcut prefix before replaying the task to the engine.
+ if (shortcut && engineChoice) prompt = shortcut.task;
+ if (shortcut && !engineChoice) {
+ const shortcuts = getModelShortcuts();
+ const target = Object.hasOwn(shortcuts, shortcut.name) ? shortcuts[shortcut.name] : null;
+ if (!target) {
+ await client.chat.postMessage({ channel: event.channel, thread_ts: threadKey, text: `Unknown model shortcut \`:${shortcut.name}\`. Ask an admin to add it in Settings → Model shortcuts.` });
+ return;
+ }
+ if (!isDM && !canChangeChannelRuntime(authorIsAdmin)) {
+ await client.chat.postMessage({ channel: event.channel, thread_ts: threadKey, text: "Only admins can change the model in this channel." });
+ return;
+ }
+ if (!isEngineEnabled(target.engine) || !modelBelongsToEngine(target.model, target.engine)) {
+ await client.chat.postMessage({ channel: event.channel, thread_ts: threadKey, text: `Model shortcut \`:${shortcut.name}\` needs an enabled engine and valid model. Update it in Settings.` });
+ return;
+ }
+ if (meta.codexAuthSource === "channel" && target.engine !== "codex") {
+ await client.chat.postMessage({ channel: event.channel, thread_ts: threadKey, text: "This channel uses its own Codex login, so its engine stays Codex." });
+ return;
+ }
+ engineSwitched = (await getThreadEngine(entry.slug, threadKey)) !== target.engine;
+ await setThreadEngine(entry.slug, threadKey, target.engine);
+ await setThreadModel(entry.slug, threadKey, target.model);
+ // Effort is model-specific. A prior thread pin must not leak into a new shortcut.
+ await setThreadEffort(entry.slug, threadKey, "");
+ prompt = shortcut.task;
+ if (!prompt && files.length === 0) {
+ await client.chat.postMessage({ channel: event.channel, thread_ts: threadKey, text: `✅ This thread now uses *${engineLabel(target.engine)}* · \`${target.model}\`.` });
+ return;
+ }
+ }
// A harness-switch card click: the person chose where this message runs. "Switch" pins the
// thread there (the same per-thread choice the `claude` / `codex` directive makes); "try
// again" leaves the thread as it is. Any engine directive in the text was already applied
@@ -1101,7 +1139,7 @@ export async function processMessageEvent(event, client, { botUserId = "", teamI
// switch phrase ("try again with codex", "switch to codex", "use claude") — a switch verb
// immediately before the engine name, so an incidental mention ("the codex CLI") won't flip it.
// It sticks until changed; the rest of the message is the task.
- if (!syntheticFork && files.length === 0) {
+ if (!syntheticFork && !shortcut && files.length === 0) {
const trimmed = prompt.trim();
const anchored = new RegExp(`^(${engineIdAlternation()})\\b[\\s:,.;–—-]*([\\s\\S]*)$`, "i").exec(trimmed);
// Only a switch INTENT near the START counts (index ≤ 12, allowing a short lead like
diff --git a/src/web/routes/settings.js b/src/web/routes/settings.js
index 313e63e..225abd2 100644
--- a/src/web/routes/settings.js
+++ b/src/web/routes/settings.js
@@ -51,6 +51,7 @@ import { isSubscriptionName } from "../../platforms/googlechat/pubsub.js";
import { logEvent } from "../../util/logger.js";
import { checkForUpdate, startUpdate } from "../../gateway/updater.js";
import { isValidModel } from "../../slack/util.js";
+import { MODEL_SHORTCUT_NAME_RE, MAX_MODEL_SHORTCUTS } from "../../config/model-shortcuts.js";
// Organization-wide environment secrets. WRITE-ONLY, exactly like the per-channel ones:
// listOrgEnv is the only shape that may leave the process (config/scoped-env.js).
import { listOrgEnv, patchOrgEnv } from "../../config/scoped-env.js";
@@ -341,6 +342,20 @@ export function createSettingsRouter({
patch.engine = body.engine;
}
if (typeof body.modelChangeAccess === "string" && MODEL_CHANGE_ACCESS_MODES.includes(body.modelChangeAccess)) patch.modelChangeAccess = body.modelChangeAccess;
+ if (body.modelShortcuts !== undefined) {
+ const entries = body.modelShortcuts && typeof body.modelShortcuts === "object" && !Array.isArray(body.modelShortcuts)
+ ? Object.entries(body.modelShortcuts) : null;
+ if (!entries || entries.length > MAX_MODEL_SHORTCUTS) return res.status(400).json({ error: `modelShortcuts must contain at most ${MAX_MODEL_SHORTCUTS} names` });
+ const shortcuts = {};
+ for (const [rawName, target] of entries) {
+ const name = rawName.toLowerCase();
+ if (!MODEL_SHORTCUT_NAME_RE.test(name) || Object.hasOwn(shortcuts, name)) return res.status(400).json({ error: `invalid or duplicate model shortcut "${rawName}"` });
+ if (!target || !ENGINES.includes(target.engine) || typeof target.model !== "string" || !isValidModel(target.model) || !modelBelongsToEngine(target.model, target.engine))
+ return res.status(400).json({ error: `invalid model target for shortcut "${rawName}"` });
+ shortcuts[name] = { engine: target.engine, model: target.model };
+ }
+ patch.modelShortcuts = shortcuts;
+ }
// Gateway default model per engine (blank clears → CLI default). Same isValidModel guard as
// /model and the channel-meta routes — a typo'd id here would break EVERY defaulted run.
// Keys come from the ADAPTERS, not a literal pair: a harness added later (Qwen) would
diff --git a/test/model-shortcut-pipeline.test.js b/test/model-shortcut-pipeline.test.js
new file mode 100644
index 0000000..c67908d
--- /dev/null
+++ b/test/model-shortcut-pipeline.test.js
@@ -0,0 +1,101 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { ensureTestEnv } from "./helpers.js";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+ensureTestEnv();
+const { useFakeRuntime } = await import("./runtime-fake.js");
+const runtime = await useFakeRuntime();
+process.env.PATH = `${fileURLToPath(new URL("./fixtures", import.meta.url))}${path.delimiter}${process.env.PATH || ""}`;
+process.env.SESSION_KEEPALIVE = "0";
+process.env.PROGRESS_VIEW = "shimmer";
+const { setUser, upsertChannelEntry, saveChannelMeta } = await import("../src/config/store.js");
+const { saveSettings } = await import("../src/config/settings.js");
+const { getThreadEngine, getThreadModel, getThreadEffort, setThreadEffort } = await import("../src/gateway/thread-engine.js");
+const { processMessageEvent } = await import("../src/slack/message-pipeline.js");
+const { stripMentions } = await import("../src/slack/message-normalize.js");
+const { parseModelShortcut } = await import("../src/config/model-shortcuts.js");
+
+const USER = "U_SHORTCUT";
+const BOT = "BSHORTCUT";
+const CHANNEL = "D_SHORTCUT";
+const messages = [];
+const client = {
+ chat: { postMessage: async (message) => { messages.push(message); return { ok: true, ts: "bot.1" }; }, update: async () => ({ ok: true }) },
+ apiCall: async () => ({ ok: true }),
+ users: { info: async ({ user }) => ({ user: { id: user, real_name: "Shortcut User" } }), list: async () => ({ members: [] }) },
+ chatStream: ({ channel, thread_ts }) => {
+ let text = "";
+ return { ts: "stream.1", append: async ({ markdown_text = "" }) => { text += markdown_text; },
+ stop: async ({ markdown_text = "" } = {}) => { messages.push({ channel, thread_ts, text: text + markdown_text }); return { ok: true }; } };
+ },
+ conversations: {
+ info: async () => ({ channel: { id: CHANNEL, is_im: true } }),
+ replies: async () => ({ messages: [] }),
+ history: async () => ({ messages: [] }),
+ },
+};
+
+test("Slack's inserted space before :astra switches a DM thread and clears an old effort pin", async () => {
+ await setUser(USER, { name: "Shortcut User", approved: true, isAdmin: false });
+ const entry = await upsertChannelEntry(CHANNEL, { name: "shortcut-dm", type: "im", isDM: true });
+ await saveChannelMeta(entry.slug, { channelId: CHANNEL, name: entry.name, type: "im", isDM: true, template: "custom", engine: "claude" });
+ saveSettings({ modelShortcuts: { astra: { engine: "codex", model: "gpt-6-astra" } } });
+ await setThreadEffort(entry.slug, "123.001", "high");
+ assert.equal(stripMentions(`<@${BOT}> :astra`, BOT), ":astra");
+ assert.deepEqual(parseModelShortcut(stripMentions(`<@${BOT}> :astra review this`, BOT)), { name: "astra", task: "review this" });
+ await processMessageEvent({ type: "message", channel: CHANNEL, channel_type: "im", user: USER, text: `<@${BOT}> :astra`, ts: "123.001" }, client, { botUserId: BOT });
+ assert.equal(await getThreadEngine(entry.slug, "123.001"), "codex");
+ assert.equal(await getThreadModel(entry.slug, "123.001"), "gpt-6-astra");
+ assert.equal(await getThreadEffort(entry.slug, "123.001"), "");
+ assert.match(messages.at(-1).text, /gpt-6-astra/);
+});
+
+test("unknown shortcut cannot change the thread", async () => {
+ const entry = await upsertChannelEntry(CHANNEL, { name: "shortcut-dm", type: "im", isDM: true });
+ await processMessageEvent({ type: "message", channel: CHANNEL, channel_type: "im", user: USER, text: `<@${BOT}> :unknown`, ts: "123.002" }, client, { botUserId: BOT });
+ assert.equal(await getThreadEngine(entry.slug, "123.002"), "");
+ assert.match(messages.at(-1).text, /Unknown model shortcut/);
+});
+
+test("shortcut tasks start and resume the mapped model, switch engines, and strip the prefix on card retries", async () => {
+ const entry = await upsertChannelEntry(CHANNEL, { name: "shortcut-dm", type: "im", isDM: true });
+ saveSettings({ modelShortcuts: { astra: { engine: "codex", model: "gpt-6-astra" }, opus: { engine: "claude", model: "claude-opus-5-5" } } });
+ const send = (text, ts, extra = {}) => processMessageEvent({ type: "message", channel: CHANNEL, channel_type: "im", user: USER, text, ts, thread_ts: "124.001" }, client, { botUserId: BOT, ...extra });
+ await send(`<@${BOT}> :astra first task`, "124.001");
+ assert.ok(messages.some((m) => /Codex stub reply.*model=gpt-6-astra/.test(m.text)), JSON.stringify(messages));
+ assert.equal(await getThreadModel(entry.slug, "124.001"), "gpt-6-astra");
+ saveSettings({ modelShortcuts: { astra: { engine: "codex", model: "gpt-6.1-sol" }, opus: { engine: "claude", model: "claude-opus-5-5" } } });
+ await send("continue", "124.002");
+ assert.ok(messages.some((m) => /Codex stub reply.*resume=yes.*model=gpt-6-astra/.test(m.text)), "repointing a name must not change a thread's resolved pin");
+ await send(`<@${BOT}> :opus next task`, "124.003");
+ assert.equal(await getThreadEngine(entry.slug, "124.001"), "claude");
+ assert.ok(messages.some((m) => /Stub engine reply.*model=claude-opus-5-5/.test(m.text)), "a new Claude process uses the shortcut's model");
+ await send(`<@${BOT}> :opus next task`, "124.004", { engineChoice: "claude" });
+ const retryPrompt = runtime.calls.spawn.at(-1).args.find((arg) => arg.includes("next task"));
+ assert.ok(retryPrompt, "a card retry still sends the task to the engine");
+ assert.doesNotMatch(retryPrompt, /:opus/, "the shortcut prefix is stripped from a card retry");
+});
+
+test("runtime permission, disabled engine and dedicated Codex login refuse a shortcut before changing pins", async () => {
+ const channel = "CSHORTCUT";
+ const entry = await upsertChannelEntry(channel, { name: "shortcut-channel", type: "channel", isDM: false });
+ const meta = { channelId: channel, name: entry.name, type: "channel", isDM: false, template: "custom", allowedUsers: [USER], engine: "claude" };
+ await saveChannelMeta(entry.slug, meta);
+ const shortcuts = { astra: { engine: "codex", model: "gpt-6-astra" }, opus: { engine: "claude", model: "claude-opus-5-5" } };
+ const send = (name, ts) => processMessageEvent({ type: "message", channel, channel_type: "channel", user: USER, text: `<@${BOT}> :${name}`, ts }, client, { botUserId: BOT });
+ saveSettings({ modelShortcuts: shortcuts, modelChangeAccess: "admins" });
+ await send("astra", "125.001");
+ assert.match(messages.at(-1).text, /Only admins/);
+ assert.equal(await getThreadEngine(entry.slug, "125.001"), "");
+ saveSettings({ modelChangeAccess: "users", engineEnabled: { claude: true, codex: false } });
+ await send("astra", "125.002");
+ assert.match(messages.at(-1).text, /enabled engine/);
+ assert.equal(await getThreadModel(entry.slug, "125.002"), "");
+ saveSettings({ engineEnabled: { claude: true, codex: true } });
+ await saveChannelMeta(entry.slug, { ...meta, codexAuthSource: "channel", engine: "codex" });
+ await send("opus", "125.003");
+ assert.match(messages.at(-1).text, /engine stays Codex/);
+ assert.equal(await getThreadEngine(entry.slug, "125.003"), "");
+});
diff --git a/test/model-shortcuts-browser.test.js b/test/model-shortcuts-browser.test.js
new file mode 100644
index 0000000..d7bb4c2
--- /dev/null
+++ b/test/model-shortcuts-browser.test.js
@@ -0,0 +1,62 @@
+// Real settings UI against a disposable gateway store. No production settings change.
+import test from "node:test";
+import assert from "node:assert/strict";
+import express from "express";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { ensureTestEnv } from "./helpers.js";
+ensureTestEnv();
+
+test("settings creates, repoints and removes model shortcuts through the live catalog", { skip: !process.env.CG_BROWSER_MODULE }, async (t) => {
+ const { chromium } = await import(process.env.CG_BROWSER_MODULE);
+ const { createAdminRouter } = await import("../src/web/routes/admin.js");
+ const { getModelShortcuts } = await import("../src/config/settings.js");
+ const app = express();
+ app.use(express.json());
+ app.get("/api/skills", (_req, res) => res.json({ skills: [] }));
+ app.get("/api/mcp/available", (_req, res) => res.json({ servers: [] }));
+ app.get("/api/health", (_req, res) => res.json({ slack: { connected: false, status: "off" }, engines: {} }));
+ app.use("/api", createAdminRouter({ slack: { snapshot: () => ({ connected: false, status: "disconnected" }) } }));
+ const publicDir = fileURLToPath(new URL("../public", import.meta.url));
+ app.use(express.static(publicDir, { dotfiles: "allow" }));
+ app.get("/settings", (_req, res) => res.sendFile(path.join(publicDir, "index.html"), { dotfiles: "allow" }));
+ const server = await new Promise((resolve) => { const s = app.listen(0, "127.0.0.1", () => resolve(s)); });
+ t.after(() => new Promise((resolve) => { server.closeAllConnections(); server.close(resolve); }));
+ const browser = await chromium.launch({ headless: true, args: ["--no-sandbox"] });
+ t.after(() => browser.close());
+ const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
+ const errors = [];
+ page.on("pageerror", (error) => errors.push(error.message));
+ await page.goto(`http://127.0.0.1:${server.address().port}/settings`);
+ await page.waitForFunction(() => globalThis.document.querySelector("#add-model-shortcut")?.disabled === false);
+ const persist = async () => {
+ const response = page.waitForResponse((r) => r.url().endsWith("/api/settings") && r.request().method() === "PUT");
+ await page.locator("#save-settings").click();
+ assert.equal((await response).status(), 200);
+ await page.waitForFunction(() => globalThis.document.querySelector("#settings-dirty-msg")?.textContent === "All changes saved");
+ };
+ await page.locator("#add-model-shortcut").click();
+ const row = page.locator(".model-shortcut-row").first();
+ await row.locator(".shortcut-name").fill("astra");
+ await row.locator(".shortcut-engine").selectOption("codex");
+ const modelIds = await row.locator(".shortcut-model option").evaluateAll((options) => options.map((option) => option.value));
+ assert.ok(modelIds.length > 1, "the actual engine catalog supplies choices");
+ await row.locator(".shortcut-model").selectOption(modelIds[0]);
+ await persist();
+ assert.deepEqual(getModelShortcuts().astra, { engine: "codex", model: modelIds[0] });
+ await page.reload();
+ await row.locator(".shortcut-model").waitFor();
+ assert.equal(await row.locator(".shortcut-name").inputValue(), "astra");
+ assert.equal(await row.locator(".shortcut-model").inputValue(), modelIds[0]);
+ await row.locator(".shortcut-model").selectOption(modelIds[1]);
+ await persist();
+ assert.equal(getModelShortcuts().astra.model, modelIds[1]);
+ await row.locator(".shortcut-engine").selectOption("claude");
+ const claudeModel = await row.locator(".shortcut-model").inputValue();
+ await persist();
+ assert.deepEqual(getModelShortcuts().astra, { engine: "claude", model: claudeModel });
+ await row.locator(".shortcut-remove").click();
+ await persist();
+ assert.deepEqual(getModelShortcuts(), {});
+ assert.deepEqual(errors, [], "the settings page has no JavaScript errors");
+});
diff --git a/test/model-shortcuts.test.js b/test/model-shortcuts.test.js
new file mode 100644
index 0000000..194dcb2
--- /dev/null
+++ b/test/model-shortcuts.test.js
@@ -0,0 +1,58 @@
+import test, { after } from "node:test";
+import assert from "node:assert/strict";
+import express from "express";
+import { ensureTestEnv } from "./helpers.js";
+
+ensureTestEnv();
+const { parseModelShortcut } = await import("../src/config/model-shortcuts.js");
+const { getModelShortcuts, settingsForApi, saveSettings } = await import("../src/config/settings.js");
+const { createAdminRouter } = await import("../src/web/routes/admin.js");
+
+const app = express();
+app.use(express.json());
+app.use(createAdminRouter({ slack: { snapshot: () => ({ connected: false }) } }));
+const server = await new Promise((resolve) => {
+ const listening = app.listen(0, "127.0.0.1", () => resolve(listening));
+});
+after(() => server.close());
+
+async function put(modelShortcuts, expectedStatus = 200) {
+ const response = await fetch(`http://127.0.0.1:${server.address().port}/settings`, {
+ method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ modelShortcuts }),
+ });
+ assert.equal(response.status, expectedStatus);
+ return response.json();
+}
+
+test("mention suffix parses a shortcut and preserves the task", () => {
+ assert.deepEqual(parseModelShortcut(":astra build this"), { name: "astra", task: "build this" });
+ assert.deepEqual(parseModelShortcut(": FABLE"), { name: "fable", task: "" });
+ assert.equal(parseModelShortcut("review :astra"), null);
+ assert.equal(parseModelShortcut(":astra, build this"), null);
+});
+
+test("settings save and expose valid engine/model mappings", async () => {
+ await put({ astra: { engine: "codex", model: "gpt-6-astra" }, opus: { engine: "claude", model: "claude-opus-5-5" } });
+ assert.deepEqual(getModelShortcuts(), {
+ astra: { engine: "codex", model: "gpt-6-astra" },
+ opus: { engine: "claude", model: "claude-opus-5-5" },
+ });
+ assert.deepEqual(settingsForApi().modelShortcuts, getModelShortcuts());
+ await put({ astra: { engine: "codex", model: "gpt-5.6-sol" } });
+ assert.equal(getModelShortcuts().astra.model, "gpt-5.6-sol", "an admin can repoint the shortcut when models change");
+ await put({});
+ assert.deepEqual(getModelShortcuts(), {});
+});
+
+test("invalid shortcut saves leave the old mapping intact", async () => {
+ saveSettings({ modelShortcuts: { sol: { engine: "codex", model: "gpt-5.6-sol" } } });
+ for (const invalid of [
+ { "bad name": { engine: "codex", model: "gpt-5.6-sol" } },
+ { sol: { engine: "claude", model: "gpt-5.6-sol" } },
+ { sol: { engine: "codex", model: "not a model" } },
+ { SOL: { engine: "codex", model: "gpt-5.6-sol" }, sol: { engine: "codex", model: "gpt-5.6-sol" } },
+ ]) {
+ await put(invalid, 400);
+ assert.equal(getModelShortcuts().sol.model, "gpt-5.6-sol");
+ }
+});