From 28178ce1ed5030f184fed34e599f478fdc3df7dc Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Sun, 4 Oct 2026 17:35:54 +0300 Subject: [PATCH 1/5] feat: add configurable model shortcuts for Slack threads Signed-off-by: Tiberiu Socaci --- CHANGELOG.md | 2 + FEATURES.md | 4 ++ TEST-PLAN.md | 8 ++++ public/app.js | 59 +++++++++++++++++++++++++++- public/index.html | 6 +++ src/config/model-shortcuts.js | 19 +++++++++ src/config/settings.js | 6 +++ src/slack/help.js | 2 +- src/slack/message-pipeline.js | 39 +++++++++++++++++- src/web/routes/settings.js | 15 +++++++ test/model-shortcut-pipeline.test.js | 43 ++++++++++++++++++++ test/model-shortcuts.test.js | 58 +++++++++++++++++++++++++++ 12 files changed, 257 insertions(+), 4 deletions(-) create mode 100644 src/config/model-shortcuts.js create mode 100644 test/model-shortcut-pipeline.test.js create mode 100644 test/model-shortcuts.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index a70b447c..49bb8302 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, mention the bot as `@agent:astra` to pin that model to a thread, with an optional task after the name. Mappings can be updated as models change. + - Add `@agent /fork ` for Claude and Codex sessions in Slack. It starts a separate engine branch in a linked new thread and runs the supplied message there. - Fix a history-transfer deadlock when `/sudo` crosses a container whose mounts need recreation. diff --git a/FEATURES.md b/FEATURES.md index c6de20ee..dc657ec8 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. 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. + ## Runtime history transfer and Google Chat failure recovery - Switching a thread between its container and `/sudo` host carries native history without diff --git a/TEST-PLAN.md b/TEST-PLAN.md index 1ef86bec..f5e0c898 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -1,5 +1,13 @@ # ChannelGate — Test Plan +## Model shortcuts acceptance (2026-10-04) + +- [x] Automated: `test/model-shortcuts.test.js` checks parsing, saving, repointing, clearing and refusing invalid mappings. +- [ ] 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, send `@agent:astra summarize this thread` as a new root message. 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). + ## Runtime history transfer and Google Chat failure recovery (2026-10-02) - [x] Automated: `test/session-carry.test.js`, `test/container-carry.test.js`, diff --git a/public/app.js b/public/app.js index 07daf2d7..a69a53e5 100644 --- a/public/app.js +++ b/public/app.js @@ -180,6 +180,59 @@ 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); +} + +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) { @@ -3606,6 +3659,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, @@ -3739,6 +3793,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; @@ -4147,7 +4202,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 70bf4dae..70c2381c 100644 --- a/public/index.html +++ b/public/index.html @@ -908,6 +908,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 followed by a colon and a name, 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 00000000..66cd1761 --- /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 019011b0..9f321832 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 baea5b6d..38258dfe 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 29149064..5bdf0744 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,40 @@ 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 && !engineChoice ? parseModelShortcut(prompt) : null; + if (shortcut) { + 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 +1136,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 313e63e4..225abd2b 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 00000000..56d92427 --- /dev/null +++ b/test/model-shortcut-pipeline.test.js @@ -0,0 +1,43 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { ensureTestEnv } from "./helpers.js"; + +ensureTestEnv(); +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 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" }; } }, + apiCall: async () => ({ ok: true }), + conversations: { + info: async () => ({ channel: { id: CHANNEL, is_im: true } }), + replies: async () => ({ messages: [] }), + history: async () => ({ messages: [] }), + }, +}; + +test("colon mention 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"); + 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/); +}); diff --git a/test/model-shortcuts.test.js b/test/model-shortcuts.test.js new file mode 100644 index 00000000..194dcb24 --- /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"); + } +}); From 98431f96ba4d360ad92506978a83aa0a1dc1b21c Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Sun, 4 Oct 2026 17:37:24 +0300 Subject: [PATCH 2/5] fix: preserve live model catalog when showing saved shortcuts Signed-off-by: Tiberiu Socaci --- public/app.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/app.js b/public/app.js index a69a53e5..0fc88fb1 100644 --- a/public/app.js +++ b/public/app.js @@ -202,7 +202,7 @@ function addModelShortcutRow(name = "", target = {}) { 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] || []); + 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] || ""); From 7c294e35f867123112cb243bb4ed88391d7e7185 Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Sun, 4 Oct 2026 17:45:06 +0300 Subject: [PATCH 3/5] test: cover Slack's spaced mention shortcut syntax Signed-off-by: Tiberiu Socaci --- CHANGELOG.md | 2 +- FEATURES.md | 2 +- TEST-PLAN.md | 6 +++--- public/index.html | 2 +- src/slack/help.js | 2 +- test/model-shortcut-pipeline.test.js | 10 +++++++--- 6 files changed, 14 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49bb8302..16c6ab55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ product overview. ## Unreleased -- Gateway Settings now lets admins map short names to an engine and model. In Slack, mention the bot as `@agent:astra` to pin that model to a thread, with an optional task after the name. Mappings can be updated as models change. +- 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. - Add `@agent /fork ` for Claude and Codex sessions in Slack. It starts a separate engine branch in a linked new thread and runs the supplied message there. diff --git a/FEATURES.md b/FEATURES.md index dc657ec8..282786ea 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -2,7 +2,7 @@ ## 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. 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. +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. ## Runtime history transfer and Google Chat failure recovery diff --git a/TEST-PLAN.md b/TEST-PLAN.md index f5e0c898..da81f933 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -4,9 +4,9 @@ - [x] Automated: `test/model-shortcuts.test.js` checks parsing, saving, repointing, clearing and refusing invalid mappings. - [ ] 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, send `@agent:astra summarize this thread` as a new root message. 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). +- [ ] 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). ## Runtime history transfer and Google Chat failure recovery (2026-10-02) diff --git a/public/index.html b/public/index.html index 70c2381c..df6e4bd1 100644 --- a/public/index.html +++ b/public/index.html @@ -910,7 +910,7 @@

Runtime changes

Model shortcuts

-

Mention the bot followed by a colon and a name, 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.

diff --git a/src/slack/help.js b/src/slack/help.js index 38258dfe..950fb0d0 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; `@agent:shortcut task` picks a configured model for this thread; `/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/test/model-shortcut-pipeline.test.js b/test/model-shortcut-pipeline.test.js index 56d92427..13de6f99 100644 --- a/test/model-shortcut-pipeline.test.js +++ b/test/model-shortcut-pipeline.test.js @@ -7,6 +7,8 @@ const { setUser, upsertChannelEntry, saveChannelMeta } = await import("../src/co 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"; @@ -22,13 +24,15 @@ const client = { }, }; -test("colon mention switches a DM thread and clears an old effort pin", async () => { +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"); - await processMessageEvent({ type: "message", channel: CHANNEL, channel_type: "im", user: USER, text: `<@${BOT}>:astra`, ts: "123.001" }, client, { botUserId: BOT }); + 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"), ""); @@ -37,7 +41,7 @@ test("colon mention switches a DM thread and clears an old effort pin", async () 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 }); + 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/); }); From 84343cf934cf01df0a7df5dc3861a28b220ea88f Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Tue, 6 Oct 2026 16:37:43 +0300 Subject: [PATCH 4/5] fix: preserve shortcut tasks through runtime card retries Signed-off-by: Tiberiu Socaci --- TEST-PLAN.md | 5 ++- src/slack/message-pipeline.js | 7 +++- test/model-shortcut-pipeline.test.js | 56 ++++++++++++++++++++++++- test/model-shortcuts-browser.test.js | 62 ++++++++++++++++++++++++++++ 4 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 test/model-shortcuts-browser.test.js diff --git a/TEST-PLAN.md b/TEST-PLAN.md index 45d39996..83acd636 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -1,8 +1,11 @@ # ChannelGate — Test Plan -## Model shortcuts acceptance (2026-10-04) +## 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. diff --git a/src/slack/message-pipeline.js b/src/slack/message-pipeline.js index 5bdf0744..9b2877bc 100644 --- a/src/slack/message-pipeline.js +++ b/src/slack/message-pipeline.js @@ -1086,8 +1086,11 @@ export async function processMessageEvent(event, client, { botUserId = "", teamI // 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 && !engineChoice ? parseModelShortcut(prompt) : null; - if (shortcut) { + 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) { diff --git a/test/model-shortcut-pipeline.test.js b/test/model-shortcut-pipeline.test.js index 13de6f99..c67908dc 100644 --- a/test/model-shortcut-pipeline.test.js +++ b/test/model-shortcut-pipeline.test.js @@ -1,8 +1,15 @@ 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"); @@ -15,8 +22,14 @@ const BOT = "BSHORTCUT"; const CHANNEL = "D_SHORTCUT"; const messages = []; const client = { - chat: { postMessage: async (message) => { messages.push(message); return { ok: true, ts: "bot.1" }; } }, + 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: [] }), @@ -45,3 +58,44 @@ test("unknown shortcut cannot change the thread", async () => { 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 00000000..f0fe797b --- /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("#settings-dirty-msg")?.textContent === "All changes saved"); + 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"); +}); From ef144cde7f7ede2f3edd44a6cc59b20d4bd7067b Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Tue, 6 Oct 2026 16:40:00 +0300 Subject: [PATCH 5/5] fix: wait for the model catalog before adding shortcuts Signed-off-by: Tiberiu Socaci --- public/app.js | 1 + public/index.html | 2 +- test/model-shortcuts-browser.test.js | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/public/app.js b/public/app.js index ba8471e6..172bb548 100644 --- a/public/app.js +++ b/public/app.js @@ -221,6 +221,7 @@ 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() { diff --git a/public/index.html b/public/index.html index ab329ff9..36ae4b13 100644 --- a/public/index.html +++ b/public/index.html @@ -921,7 +921,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.

- +

Container runtime

diff --git a/test/model-shortcuts-browser.test.js b/test/model-shortcuts-browser.test.js index f0fe797b..d7bb4c24 100644 --- a/test/model-shortcuts-browser.test.js +++ b/test/model-shortcuts-browser.test.js @@ -28,7 +28,7 @@ test("settings creates, repoints and removes model shortcuts through the live ca 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("#settings-dirty-msg")?.textContent === "All changes saved"); + 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();