Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ product overview.
- 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.

- Add a default effort selector beside each harness's default model, including Qwen providers.
Inherited runs use it unless a conversation, thread or request overrides effort; blank keeps
the CLI default. Validate model compatibility and use the destination harness's effort on failover.

- 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
Expand Down
6 changes: 6 additions & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2458,6 +2458,12 @@ A categorized catalog of what's shipped. Cross-linked to `TEST-PLAN.md` checks.
logins stay on Codex. Unknown, disabled or invalid targets and missing request text are refused
before a child thread is posted.
A running or sudo/source-host session is refused until it can be forked safely.
- Gateway-wide **default effort** beside each harness's default model (Settings → Agent defaults →
Engine & runtime, including each Qwen provider card). Choices follow the selected model's
supported effort levels; saves validate the model/effort pair atomically. Per-request, thread
and channel/DM-template effort overrides take precedence. Inherited fresh and resumed turns use
the harness default; blank preserves the CLI default. Failover uses the destination harness's
own default effort, and model retries recheck compatibility before passing an effort flag.
- Gateway-wide **default model** per engine (Settings → Engine & runtime, validated like `/model`):
when a thread/channel/DM-template sets no model, the run gets the gateway default as an explicit
`--model`/`-m` — so the admin's interactive terminal `/model` choice (written to
Expand Down
23 changes: 23 additions & 0 deletions TEST-PLAN.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,28 @@
# ChannelGate — Test Plan

## Harness default effort (2026-10-23)

- [x] Automated: `node --test test/default-model.test.js test/qwen-provider-settings-route.test.js
test/engine-registry.test.js test/engine-adapter-contract.test.js
test/runtime-identity-preamble.test.js` — settings round-trip, legacy/invalid/blank handling,
atomic API validation for every harness, fresh/resumed CLI flags, request > thread > channel >
gateway > CLI precedence, plus destination defaults through failover and model retry.
- [x] Browser: `CG_BROWSER_MODULE=/absolute/path/to/playwright/index.mjs node --test
test/qwen-provider-settings-browser.test.js` — disposable admin API and real Chromium;
save/reload/clear distinct Claude, Codex and Qwen EU efforts without changing production settings.
- [ ] Live Claude and Codex acceptance: for each engine, use a private read fixture with that
engine selected and model/effort unset on the channel and thread. Record existing gateway
defaults; in Admin UI → Agent defaults set the engine's model and effort to a supported pair
(Claude sonnet/high; Codex gpt-6-sol/medium). Start a fresh thread as an approved test actor:
“Which configured engine, model and effort are you running? One line.” Check captured runtime
facts and actual engine flags, not just the answer. Change gateway effort to low, send “And
now?” in the same thread, and verify the resumed turn receives low. Set channel effort medium,
then thread effort high, then request effort low through the Run API; each more specific choice
must win. Clear each override and the gateway effort; verify the effort flag is omitted. Restore
the recorded defaults. PASS only when actual spawn evidence agrees at every step for both
engines. Repeat replay-safe failover with different source/destination defaults and verify the
destination uses its own effort. Pending live execution; automated stubs are not live engine PASS.

## Admin access to Slack buttons and forms (2026-10-23)

- [x] Automated: `node --test test/admin-menu-controls.test.js test/file-button-actions.test.js
Expand Down
2 changes: 1 addition & 1 deletion docs/ENGINE-CAPABILITIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ The Qwen column covers every harness generated from the Anthropic-compatible **p
`src/engines/qwen.js` — today `qwen` (QwenCloud) and `qwen-eu` (Alibaba Cloud Model Studio, EU
region). They share this column because they share the CLI and every guarantee in it; what differs
per entry is the endpoint, the credential, the shipped and discovered model catalog, and the
gateway default model.
gateway default model and effort.

Each is **opt-in**: a missing `engineEnabled` entry means OFF, it is terminal in the failover graph
in both directions, and its provider credential (its own `…ApiKey` / `…BaseUrl` pair) is gateway
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,16 @@ Open **Settings → Agent defaults → Engine & runtime**.
| Engine (which CLI drives the bot) | Default engine; Claude is the fallback default when none is configured |
| Default Claude model | Sets the model used by inherited Claude runs |
| Default Codex model | Sets the model used by inherited Codex runs |
| Default Claude / Codex effort | Sets reasoning effort when a conversation or thread does not override it |
| Provider default model and effort | Independent defaults on each optional Qwen provider card |
| Automatic failover between harnesses | Enables supported Claude–Codex recovery before work starts; defaults on |
| How a failover happens | Automatic, or ask in a live Slack thread; automatic is the default |

Optional provider harnesses must be explicitly enabled and authenticated. At least one engine must remain enabled. A model listed for one engine is not interchangeable with another engine's model.

1. Authenticate the engines you intend to use.
2. Disable engines the deployment cannot support.
3. Choose the default engine and an explicit model for each enabled engine when its controls offer one.
3. Choose the default engine, model, and supported effort for each enabled engine when its controls offer them.
4. Save, then create a fresh thread to check inherited behavior.

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.
Expand All @@ -47,7 +49,7 @@ Set a conversation's **Engine**, **Model**, and **Effort** under **Conversations

For a new run, explicit per-run overrides take precedence, followed by thread selection, conversation/DM selection, and gateway defaults. Existing threads retain their owning engine session unless explicitly switched or recovered; changing the global default does not automatically migrate every active thread.

Effort is engine- and model-dependent. Use the offered choices rather than assuming all models support `xhigh` or the same reasoning settings. An empty effort value inherits the engine's default; it is not proof of a specific reasoning level.
Effort is engine- and model-dependent. Use the offered choices rather than assuming all models support `xhigh` or the same reasoning settings. An empty conversation or thread effort inherits the gateway's default effort for that harness. An empty gateway effort uses the CLI default; it is not proof of a specific reasoning level. Failover uses the destination harness's own gateway effort, checked against its selected model.

## Control who changes runtime

Expand Down
22 changes: 18 additions & 4 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -128,14 +128,14 @@ function engineOptionsHtml({ includeDefault = false } = {}) {
selectableEngines().map((m) => `<option value="${escapeHtml(m.id)}">${escapeHtml(m.label)}</option>`).join("");
}

function syncEffortOptions({ engineSelect, modelSelect, effortSelect, label, value }) {
const engine = effectiveEngine(engineSelect?.value || "");
function syncEffortOptions({ engineSelect, modelSelect, effortSelect, label, value, engine: pinnedEngine, blankLabel = "default" }) {
const engine = pinnedEngine || effectiveEngine(engineSelect?.value || "");
const modelEfforts = MODEL_EFFORT_OPTIONS[engine]?.[modelSelect?.value || ""];
const options = modelEfforts?.length
? [["", "default"], ...modelEfforts.map((effort) => [effort, effort])]
: EFFORT_OPTIONS[engine] || EFFORT_OPTIONS.claude;
const current = value !== undefined ? value : effortSelect.value;
effortSelect.innerHTML = options.map(([v, text]) => `<option value="${escapeHtml(v)}">${escapeHtml(text)}</option>`).join("");
effortSelect.innerHTML = options.map(([v, text]) => `<option value="${escapeHtml(v)}">${escapeHtml(v ? text : blankLabel)}</option>`).join("");
effortSelect.value = options.some(([v]) => v === current) ? current : "";
if (label) label.textContent = `Effort (${ENGINE_MANIFESTS.find((m) => m.id === engine)?.label || engine})`;
}
Expand Down Expand Up @@ -187,6 +187,12 @@ function syncModelOptions({ engineSelect, modelSelect, value, engine, blankLabel
modelSelect.value = options.some(([v]) => v === current) ? current : "";
}

function syncDefaultEffort(modelSelect, effortSelect, engine, value) {
const sync = (next) => syncEffortOptions({ modelSelect, effortSelect, engine, value: next, blankLabel: "CLI default" });
sync(value || "");
modelSelect.onchange = () => sync(effortSelect.value);
}

function addModelShortcutRow(name = "", target = {}) {
const box = document.getElementById("model-shortcuts-editor");
const row = document.createElement("div");
Expand Down Expand Up @@ -3769,6 +3775,7 @@ function paintQwenProviders(providers) {
const keyInput = at("api-key");
const urlInput = at("base-url");
const modelSelect = at("default-model");
const effortSelect = at("default-effort");
const clearBtn = at("clear-key");
// An endpoint the provider ships is a placeholder (blank = keep it); one only the operator can
// know is not, so the box says so instead of implying a default exists.
Expand All @@ -3779,6 +3786,7 @@ function paintQwenProviders(providers) {
// The button sits inside the field's <label>; preventDefault stops the click focusing the input.
clearBtn.addEventListener("click", (e) => { e.preventDefault(); toggleClearTok(clearBtn); });
syncModelOptions({ modelSelect, engine: p.id, value: p.defaultModel || "", blankLabel: "provider default" });
syncDefaultEffort(modelSelect, effortSelect, p.id, p.defaultEffort);
// Say WHERE the list came from: a stale shipped list and a live account list look identical in
// a picker, and the difference decides whether a missing model is a typo or a missing key.
const catalog = ENGINE_MANIFESTS.find((m) => m.id === p.id)?.modelCatalog;
Expand All @@ -3788,7 +3796,7 @@ function paintQwenProviders(providers) {
? "Built-in list — the account's own list could not be read. Check the key and endpoint, then reload."
: "Built-in list. Save an API key to read the account's own models.");
tpl.parentNode.insertBefore(card, tpl);
QWEN_PROVIDER_CARDS.push({ ...p, keyInput, urlInput, modelSelect, clearBtn });
QWEN_PROVIDER_CARDS.push({ ...p, keyInput, urlInput, modelSelect, effortSelect, clearBtn });
}
}

Expand All @@ -3799,6 +3807,7 @@ function collectQwenProviders() {
for (const card of QWEN_PROVIDER_CARDS) {
body[card.baseUrlField] = card.urlInput.value;
body[card.defaultModelField] = card.modelSelect.value;
body[card.defaultEffortField] = card.effortSelect.value;
const typed = tokenValue(card.keyInput);
if (typed) body[card.apiKeyField] = typed;
if (card.clearBtn.classList.contains("armed")) body[`clear${card.apiKeyField[0].toUpperCase()}${card.apiKeyField.slice(1)}`] = true;
Expand Down Expand Up @@ -3905,6 +3914,8 @@ function readSettingsForm() {
engine: document.getElementById("set-engine").value,
defaultClaudeModel: document.getElementById("set-default-claude-model").value,
defaultCodexModel: document.getElementById("set-default-codex-model").value,
defaultClaudeEffort: document.getElementById("set-default-claude-effort").value,
defaultCodexEffort: document.getElementById("set-default-codex-effort").value,
...collectQwenProviders(),
perplexityResearchEnabled: document.getElementById("set-perplexity-research-enabled").checked,
...(document.getElementById("set-perplexity-session-token").value ? { perplexitySessionToken: document.getElementById("set-perplexity-session-token").value } : {}),
Expand Down Expand Up @@ -4052,6 +4063,9 @@ function paintSettings(s) {
GLOBAL_ENGINE = s.engine || "claude";
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" });
for (const engine of ["claude", "codex"]) {
syncDefaultEffort(document.getElementById(`set-default-${engine}-model`), document.getElementById(`set-default-${engine}-effort`), engine, s[`default${engine === "claude" ? "Claude" : "Codex"}Effort`]);
}
document.getElementById("set-model-change-access").value = s.modelChangeAccess || "admins";
paintModelShortcuts(s.modelShortcuts);
document.getElementById("set-engine-fallback").checked = s.engineFallback !== false;
Expand Down
15 changes: 15 additions & 0 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -586,10 +586,20 @@ <h4>Engine &amp; runtime</h4>
<span>Default Claude model — pins gateway runs so the terminal's <code>/model</code> choice never leaks in</span>
<select id="set-default-claude-model"></select>
</label>
<label class="field">
<span>Default Claude effort</span>
<select id="set-default-claude-effort"></select>
<em class="state">Used when no thread or conversation effort is set. Blank uses the CLI default.</em>
</label>
<label class="field">
<span>Default Codex model</span>
<select id="set-default-codex-model"></select>
</label>
<label class="field">
<span>Default Codex effort</span>
<select id="set-default-codex-effort"></select>
<em class="state">Used when no thread or conversation effort is set. Blank uses the CLI default.</em>
</label>
<div class="ch-codex-login" id="gateway-codex-login">
<strong>Default gateway Codex login</strong>
<p class="desc">Shared by channels that use the gateway login.</p>
Expand Down Expand Up @@ -661,6 +671,11 @@ <h4 data-role="label"></h4>
<select data-role="default-model"></select>
<em class="state" data-role="catalog-note"></em>
</label>
<label class="field">
<span>Default effort</span>
<select data-role="default-effort"></select>
<em class="state">Used when no thread or conversation effort is set. Blank uses the CLI default.</em>
</label>
</div>
</template>
<div class="setcard">
Expand Down
11 changes: 11 additions & 0 deletions src/config/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,14 @@ export function getDefaultModel(engine) {
return typeof v === "string" ? v.trim() : "";
}

// Blank preserves the CLI default; stale or hand-edited values never reach a runner.
export function getDefaultEffort(engine) {
const adapter = adapterOr(engine);
const raw = getSettings()[adapter.defaultEffortKey];
const effort = typeof raw === "string" ? raw.trim().toLowerCase() : "";
return adapter.efforts.includes(effort) ? effort : "";
}

export function getModelShortcuts() {
return modelShortcutsFromSettings(getSettings(), { claude: modelsForEngine("claude"), codex: modelsForEngine("codex") });
}
Expand Down Expand Up @@ -493,12 +501,14 @@ export function qwenProviderSettings() {
apiKeyField: entry.settings.apiKey,
baseUrlField: entry.settings.baseUrl,
defaultModelField: entry.defaultModelKey,
defaultEffortField: entry.defaultEffortKey,
hasApiKey: Boolean(config.apiKey),
apiKeyLast4: last4(config.apiKey),
// The STORED endpoint, so an empty box means "still on the shipped default" rather than
// silently re-saving a default the operator never chose.
baseUrl: typeof getSettings()[entry.settings.baseUrl] === "string" ? getSettings()[entry.settings.baseUrl].trim() : "",
defaultModel: getDefaultModel(entry.id),
defaultEffort: getDefaultEffort(entry.id),
};
});
}
Expand Down Expand Up @@ -1153,6 +1163,7 @@ export function settingsForApi() {
engine: getEngine(),
defaultClaudeModel: getDefaultModel("claude"),
defaultCodexModel: getDefaultModel("codex"),
...Object.fromEntries(ENGINES.map((id) => [adapterOr(id).defaultEffortKey, getDefaultEffort(id)])),
// One entry per Anthropic-compatible provider, so the admin UI renders a card per harness
// instead of carrying a hard-coded copy of the table.
qwenProviders: qwenProviderSettings(),
Expand Down
8 changes: 4 additions & 4 deletions src/engines/adapters.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ const baseCompile = (engine, request = {}, supportedModes = ["off"]) => {
const claude = validateEngineAdapter({
pluginCapabilities: { manifest: "claude", components: ["skills", "commands", "agents", "hooks", "mcpServers"] },
resolveOptionalMcpConfig: resolveClaudeMcpConfig,
id: "claude", label: "Claude", cli: "claude", defaultModelKey: "defaultClaudeModel", mcpMetaKey: "allowedMcps",
id: "claude", label: "Claude", cli: "claude", defaultModelKey: "defaultClaudeModel", defaultEffortKey: "defaultClaudeEffort", mcpMetaKey: "allowedMcps",
instructionFile: "CLAUDE.md", skillsDir: ".claude/skills", mcpTransport: "file", contextWindow: 200_000,
efforts: ["low", "medium", "high", "xhigh"], models: [
{ label: "Opus 5.5", value: "claude-opus-5-5", description: "Current Opus model for most coding and knowledge work." },
Expand Down Expand Up @@ -205,7 +205,7 @@ const claude = validateEngineAdapter({
const makeQwenAdapter = (entry) => validateEngineAdapter({
pluginCapabilities: { manifest: "claude", components: ["skills", "commands", "agents", "hooks", "mcpServers"] },
resolveOptionalMcpConfig: resolveClaudeMcpConfig,
id: entry.id, label: entry.label, cli: "claude", defaultModelKey: entry.defaultModelKey,
id: entry.id, label: entry.label, cli: "claude", defaultModelKey: entry.defaultModelKey, defaultEffortKey: entry.defaultEffortKey,
// The shared model-ID vocabulary these harnesses draw from (Qwen/GLM/DeepSeek/Kimi/MiniMax).
// Declared so the admin UI can recognise a provider harness's model without a hard-coded engine
// id — a provider added to the table would otherwise have its saved model silently dropped from
Expand Down Expand Up @@ -312,7 +312,7 @@ const codex = validateEngineAdapter({
}
return { servers, rejected };
},
id: "codex", label: "Codex", cli: "codex", defaultModelKey: "defaultCodexModel", mcpMetaKey: "allowedCodexMcps",
id: "codex", label: "Codex", cli: "codex", defaultModelKey: "defaultCodexModel", defaultEffortKey: "defaultCodexEffort", mcpMetaKey: "allowedCodexMcps",
// Codex reports no dollar cost, so the ledger prices it from this configured rate (Settings →
// Audit). Declared here so no other engine inherits OpenAI's rates by falling through.
costRateKey: "codexRatePer1MTokens",
Expand Down Expand Up @@ -375,7 +375,7 @@ const codex = validateEngineAdapter({
});

const opencode = validateEngineAdapter({
id: "opencode", label: "OpenCode (read-only)", cli: "opencode", defaultModelKey: "defaultOpenCodeModel",
id: "opencode", label: "OpenCode (read-only)", cli: "opencode", defaultModelKey: "defaultOpenCodeModel", defaultEffortKey: "defaultOpenCodeEffort",
mcpMetaKey: "allowedOpenCodeMcps", instructionFile: "AGENTS.md", skillsDir: ".opencode/skills",
mcpTransport: "none", contextWindow: 200_000, efforts: ["low", "medium", "high"], models: [],
mintsOwnSessionId: true,
Expand Down
Loading
Loading