From 5cdabc832d601db42ab12a8ef626c439fe69caed Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Fri, 23 Oct 2026 18:28:50 +0300 Subject: [PATCH] feat: provide current-model shortcut defaults for new installations Signed-off-by: Tiberiu Socaci --- CHANGELOG.md | 3 ++ FEATURES.md | 8 ++++ TEST-PLAN.md | 14 +++++++ .../docs/configuration/model-defaults.md | 14 +++++++ public/index.html | 2 +- src/config/model-shortcuts.js | 38 +++++++++++++++++- src/config/settings.js | 4 +- test/model-shortcut-pipeline.test.js | 19 ++++++++- test/model-shortcuts-browser.test.js | 13 +++++- test/model-shortcuts.test.js | 40 ++++++++++++++++++- 10 files changed, 147 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3716838d..c34b0f62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ product overview. ## Unreleased +- Supply new installations with Astra, Sol, Terra, Fable, Opus and Sonnet (`:sonet`) shortcuts + using the newest supported stable family versions. Preserve customized lists and thread pins. + - Let organization admins use another requester's Slack menu buttons and question forms. Menu views open under the clicking admin's own identity; question answers continue the original requester's session and record which admin responded. Current access, membership and stale-form diff --git a/FEATURES.md b/FEATURES.md index 99849d3f..d0c9e5be 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -653,6 +653,14 @@ control-plane status. ## Gateway model shortcuts +New installations inherit `:astra`, `:sol`, `:terra`, `:fable`, `:opus` and `:sonet` from the +engine model catalogs. Each resolves to the numerically newest stable version of its family, +including Sonnet 5.5 and GPT-6.1 Sol when offered by the catalog. Live Codex discovery can advance +these defaults; Claude uses the curated supported catalog shipped with the gateway. Missing +families are omitted instead of guessed. A stored shortcut map, including an intentionally empty +map, is preserved exactly. Saving unrelated settings keeps inherited defaults; editing the shortcut +list makes it an explicit set of pins. Existing thread pins never follow catalog updates. + 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 diff --git a/TEST-PLAN.md b/TEST-PLAN.md index 824bbbaa..b685ddb6 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -1561,6 +1561,20 @@ Record blocked fixtures and unexecuted gates explicitly for both engines. ## Model shortcuts acceptance (2026-10-06) +- [x] Automated: fresh settings without `modelShortcuts` inherit the six catalog families. Numeric + ordering selects 6.10 over 6.9 and Sonnet 5.10 over 5.5 independent of catalog order; + aliases/previews and missing families never invent targets. Newer catalogs update inherited + defaults without mutating settings; explicit custom/empty maps survive and unrelated saves + keep inheritance (`test/model-shortcuts.test.js`). +- [ ] Live Claude/Codex: on a disposable installation without a saved shortcut map, inspect Settings + and send `@agent :sonet Reply exactly DEFAULT_SONNET_OK` and `@agent :sol Reply exactly + DEFAULT_SOL_OK` as Contact in the corresponding private Auto fixtures. Pass: the supported + latest family IDs are selected and pinned, exact sentinels return on the requested engine, + and normal followups retain those pins. Save unrelated settings; inherited defaults stay + inherited. Customize one mapping, then empty the list; reload/upgrade must preserve each + explicit choice. Use an isolated catalog fixture to introduce newer family versions; only + inherited mappings advance, never existing thread pins or a customized list. + - [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. diff --git a/documentation/src/content/docs/configuration/model-defaults.md b/documentation/src/content/docs/configuration/model-defaults.md index 7d1197fc..40d5eca4 100644 --- a/documentation/src/content/docs/configuration/model-defaults.md +++ b/documentation/src/content/docs/configuration/model-defaults.md @@ -27,6 +27,20 @@ Optional provider harnesses must be explicitly enabled and authenticated. At lea An empty gateway model setting lets the CLI use its own default. An explicit gateway model helps avoid changes in the operator's interactive terminal model choice affecting gateway runs. +## Default model shortcuts + +New installations include `:astra`, `:sol`, `:terra`, `:fable`, `:opus`, and `:sonet` (Sonnet). +Use `@agent :sonet your task` in Slack to select and pin the current supported Sonnet model. +Before an administrator saves a custom shortcut list, targets follow the newest stable version +of each family in the gateway's model catalog. Codex uses its authenticated CLI catalog, with a +bundled fallback; Claude uses the curated catalog in the installed gateway version. Updating the +CLI or gateway is therefore still necessary when a provider exposes a model they do not yet list. +Unavailable families are omitted rather than assigned a guessed model ID. + +Edit shortcuts under **Settings → Access & security → Model shortcuts**. A saved list uses those +exact mappings until edited again; an empty list disables shortcuts. Existing customized lists and +thread pins survive updates. Saving unrelated settings does not turn inherited defaults into pins. + ## Conversation and thread overrides Set a conversation's **Engine**, **Model**, and **Effort** under **Conversations → Runtime**. On Slack, use `/model` to select a channel-wide or **just this thread** runtime. A thread-scoped choice does not change the default for other threads. diff --git a/public/index.html b/public/index.html index e46f5839..f20c0337 100644 --- a/public/index.html +++ b/public/index.html @@ -965,7 +965,7 @@

Runtime changes

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.

+

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. New installations include Astra, Sol, Terra, Fable, Opus and :sonet for Sonnet. Until this list is customized, targets follow the newest stable version in each family from the supported model catalog. Saving an edited list keeps those exact choices.

diff --git a/src/config/model-shortcuts.js b/src/config/model-shortcuts.js index 66cd1761..339033c8 100644 --- a/src/config/model-shortcuts.js +++ b/src/config/model-shortcuts.js @@ -2,7 +2,43 @@ export const MODEL_SHORTCUT_NAME_RE = /^[a-z][a-z0-9_-]{0,23}$/; export const MAX_MODEL_SHORTCUTS = 32; -export function modelShortcutsFromSettings(settings) { +// Families are stable shortcut names; exact versions come only from the engine catalogs. +const DEFAULT_SHORTCUT_FAMILIES = [ + ["astra", "codex", /^gpt-(\d+(?:\.\d+)*)-astra$/], + ["sol", "codex", /^gpt-(\d+(?:\.\d+)*)-sol$/], + ["terra", "codex", /^gpt-(\d+(?:\.\d+)*)-terra$/], + ["fable", "claude", /^claude-fable-(\d+(?:-\d+)*)$/], + ["opus", "claude", /^claude-opus-(\d+(?:-\d+)*)$/], + ["sonet", "claude", /^claude-sonnet-(\d+(?:-\d+)*)$/], +]; + +function compareVersions(left, right) { + for (let index = 0; index < Math.max(left.length, right.length); index++) { + const delta = (left[index] || 0) - (right[index] || 0); + if (delta) return delta; + } + return 0; +} + +export function defaultModelShortcuts(catalogs = {}) { + const shortcuts = {}; + for (const [name, engine, pattern] of DEFAULT_SHORTCUT_FAMILIES) { + let latest = null; + for (const entry of catalogs[engine] || []) { + const match = pattern.exec(String(entry?.value || "")); + if (!match || /-(?:\d{8}|\d{4}-\d{2}-\d{2})$/.test(entry.value)) continue; // Ignore aliases, preview IDs and unrelated model families. + const version = match[1].split(/[.-]/).map(Number); + if (!latest || compareVersions(version, latest.version) > 0) latest = { model: entry.value, version }; + } + if (latest) shortcuts[name] = { engine, model: latest.model }; + } + return shortcuts; +} + +export function modelShortcutsFromSettings(settings, catalogs = {}) { + // A missing key inherits current catalog defaults. A saved list, including {}, is owned by + // the administrator: upgrades must never overwrite custom pins or resurrect deleted names. + if (!Object.hasOwn(settings || {}, "modelShortcuts")) return defaultModelShortcuts(catalogs); const raw = settings?.modelShortcuts; if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; return Object.fromEntries(Object.entries(raw).filter(([name, target]) => diff --git a/src/config/settings.js b/src/config/settings.js index e05f1ae2..c779de02 100644 --- a/src/config/settings.js +++ b/src/config/settings.js @@ -7,7 +7,7 @@ import path from "node:path"; import { settingsFile } from "./paths.js"; import { writeSecretFile } from "./harden.js"; import { getDb } from "../db/index.js"; -import { ENGINE_IDS, adapterOr } from "../engines/registry.js"; +import { ENGINE_IDS, adapterOr, modelsForEngine } from "../engines/registry.js"; // Value-only import (the default endpoint constant). qwen.js reads settings LAZILY, so this // direction carries no cycle. import { QWEN_PROVIDERS, qwenProvider } from "../engines/qwen.js"; @@ -335,7 +335,7 @@ export function getDefaultModel(engine) { } export function getModelShortcuts() { - return modelShortcutsFromSettings(getSettings()); + return modelShortcutsFromSettings(getSettings(), { claude: modelsForEngine("claude"), codex: modelsForEngine("codex") }); } // Who may use Slack's /model wizard in a CHANNEL (both channel-wide and thread-scoped picks). diff --git a/test/model-shortcut-pipeline.test.js b/test/model-shortcut-pipeline.test.js index c67908dc..ab6d333e 100644 --- a/test/model-shortcut-pipeline.test.js +++ b/test/model-shortcut-pipeline.test.js @@ -11,7 +11,7 @@ process.env.PATH = `${fileURLToPath(new URL("./fixtures", import.meta.url))}${pa 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 { saveSettings, getModelShortcuts, getDefaultModel, getEngine } = 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"); @@ -37,6 +37,23 @@ const client = { }, }; +test("fresh-install defaults select supported Sonnet and Sol without changing gateway defaults", 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" }); + const engine = getEngine(); + const defaults = {claude: getDefaultModel("claude"), codex: getDefaultModel("codex")}; + const shortcuts = getModelShortcuts(); + for (const [index, name] of ["sonet", "sol"].entries()) { + const ts = `122.00${index}`; + await processMessageEvent({ type: "message", channel: CHANNEL, channel_type: "im", user: USER, text: `<@${BOT}> :${name} short task`, ts }, client, { botUserId: BOT }); + assert.equal(await getThreadModel(entry.slug, ts), shortcuts[name].model); + assert.equal(await getThreadEngine(entry.slug, ts), shortcuts[name].engine); + } + assert.equal(getEngine(), engine); + assert.deepEqual({claude: getDefaultModel("claude"), codex: getDefaultModel("codex")}, defaults); +}); + 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 }); diff --git a/test/model-shortcuts-browser.test.js b/test/model-shortcuts-browser.test.js index d7bb4c24..43714dde 100644 --- a/test/model-shortcuts-browser.test.js +++ b/test/model-shortcuts-browser.test.js @@ -10,7 +10,7 @@ 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 { getModelShortcuts, saveSettings, getSettings } = await import("../src/config/settings.js"); const app = express(); app.use(express.json()); app.get("/api/skills", (_req, res) => res.json({ skills: [] })); @@ -35,6 +35,17 @@ test("settings creates, repoints and removes model shortcuts through the live ca assert.equal((await response).status(), 200); await page.waitForFunction(() => globalThis.document.querySelector("#settings-dirty-msg")?.textContent === "All changes saved"); }; + assert.equal(await page.locator(".model-shortcut-row").count(), 6, "fresh settings render all inherited families"); + assert.equal(await page.locator('.shortcut-name').evaluateAll(nodes => nodes.some(n => n.value === "sonet")), true); + await page.locator("#set-keepalive").fill("15m"); + await persist(); + assert.equal(Object.hasOwn(getSettings(), "modelShortcuts"), false, "an unrelated API save keeps defaults inherited"); + await page.reload(); + await page.waitForFunction(() => globalThis.document.querySelector("#add-model-shortcut")?.disabled === false); + assert.equal(await page.locator(".model-shortcut-row").count(), 6); + saveSettings({ modelShortcuts: {} }); + await page.reload(); + await page.waitForFunction(() => globalThis.document.querySelector("#add-model-shortcut")?.disabled === false); await page.locator("#add-model-shortcut").click(); const row = page.locator(".model-shortcut-row").first(); await row.locator(".shortcut-name").fill("astra"); diff --git a/test/model-shortcuts.test.js b/test/model-shortcuts.test.js index 194dcb24..d8c70509 100644 --- a/test/model-shortcuts.test.js +++ b/test/model-shortcuts.test.js @@ -4,8 +4,8 @@ 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 { parseModelShortcut, defaultModelShortcuts, modelShortcutsFromSettings } = await import("../src/config/model-shortcuts.js"); +const { getModelShortcuts, settingsForApi, saveSettings, getSettings } = await import("../src/config/settings.js"); const { createAdminRouter } = await import("../src/web/routes/admin.js"); const app = express(); @@ -24,6 +24,42 @@ async function put(modelShortcuts, expectedStatus = 200) { return response.json(); } +test("fresh installs inherit supported family defaults without persisting version pins", () => { + const defaults = getModelShortcuts(); + assert.deepEqual(Object.keys(defaults).sort(), ["astra", "fable", "opus", "sol", "sonet", "terra"]); + assert.equal(defaults.sonet.engine, "claude"); + assert.match(defaults.sonet.model, /^claude-sonnet-/); + assert.equal(defaults.sol.engine, "codex"); + assert.match(defaults.sol.model, /^gpt-[\d.]+-sol$/); + saveSettings({ sessionKeepalive: "10" }); + assert.deepEqual(getModelShortcuts(), defaults, "unrelated saves retain inheritance"); + assert.deepEqual(settingsForApi().modelShortcuts, defaults); + assert.equal(Object.hasOwn(getSettings(), "modelShortcuts"), false); +}); + +test("catalog defaults choose the newest stable family numerically and omit absent families", () => { + const catalogs = { + claude: ["claude-sonnet-5-9", "claude-sonnet-5-10", "claude-sonnet-6-preview", "claude-sonnet-9-20261023", "claude-sonnet-9-2026-10-23", "sonnet", "claude-opus-5-5", "claude-haiku-5-5"].map(value => ({ value })), + codex: ["gpt-6.9-sol", "gpt-6.10-sol", "gpt-6.10-sol-preview", "gpt-7-sol", "gpt-6-astra", "codex"].map(value => ({ value })), + }; + const initial = defaultModelShortcuts(catalogs); + assert.equal(initial.sonet.model, "claude-sonnet-5-10"); + assert.equal(initial.sol.model, "gpt-7-sol"); + assert.equal(defaultModelShortcuts({codex: catalogs.codex.slice(0, 2)}).sol.model, "gpt-6.10-sol"); + assert.equal(initial.fable, undefined); + assert.equal(initial.terra, undefined); + const settings = {}; + assert.deepEqual(modelShortcutsFromSettings(settings, catalogs), initial); + catalogs.claude.push({ value: "claude-sonnet-6" }); + assert.equal(modelShortcutsFromSettings(settings, catalogs).sonet.model, "claude-sonnet-6"); + assert.deepEqual(settings, {}, "resolution never freezes a version into settings"); + assert.deepEqual(modelShortcutsFromSettings({modelShortcuts: {}}, catalogs), {}); + const custom = {sonet: {engine: "claude", model: "claude-sonnet-5-5"}}; + assert.deepEqual(modelShortcutsFromSettings({modelShortcuts: custom}, catalogs), custom); + assert.deepEqual(modelShortcutsFromSettings({modelShortcuts: null}, catalogs), {}); + assert.deepEqual(defaultModelShortcuts(), {}); +}); + 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: "" });