Skip to content

Commit 47725e3

Browse files
committed
feat: integrate harness default effort settings
Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
2 parents de4d023 + 4686e84 commit 47725e3

19 files changed

Lines changed: 255 additions & 27 deletions

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ product overview.
2121
- Supply new installations with Astra, Sol, Terra, Fable, Opus and Sonnet (`:sonet`) shortcuts
2222
using the newest supported stable family versions. Preserve customized lists and thread pins.
2323

24+
- Add a default effort selector beside each harness's default model, including Qwen providers.
25+
Inherited runs use it unless a conversation, thread or request overrides effort; blank keeps
26+
the CLI default. Validate model compatibility and use the destination harness's effort on failover.
27+
2428
- Let organization admins use another requester's Slack menu buttons and question forms. Menu
2529
views open under the clicking admin's own identity; question answers continue the original
2630
requester's session and record which admin responded. Current access, membership and stale-form

‎FEATURES.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2458,6 +2458,12 @@ A categorized catalog of what's shipped. Cross-linked to `TEST-PLAN.md` checks.
24582458
logins stay on Codex. Unknown, disabled or invalid targets and missing request text are refused
24592459
before a child thread is posted.
24602460
A running or sudo/source-host session is refused until it can be forked safely.
2461+
- Gateway-wide **default effort** beside each harness's default model (Settings → Agent defaults →
2462+
Engine & runtime, including each Qwen provider card). Choices follow the selected model's
2463+
supported effort levels; saves validate the model/effort pair atomically. Per-request, thread
2464+
and channel/DM-template effort overrides take precedence. Inherited fresh and resumed turns use
2465+
the harness default; blank preserves the CLI default. Failover uses the destination harness's
2466+
own default effort, and model retries recheck compatibility before passing an effort flag.
24612467
- Gateway-wide **default model** per engine (Settings → Engine & runtime, validated like `/model`):
24622468
when a thread/channel/DM-template sets no model, the run gets the gateway default as an explicit
24632469
`--model`/`-m` — so the admin's interactive terminal `/model` choice (written to

‎TEST-PLAN.md‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,28 @@
11
# ChannelGate — Test Plan
22

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

528
- [x] Automated: `node --test test/admin-menu-controls.test.js test/file-button-actions.test.js

‎docs/ENGINE-CAPABILITIES.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ The Qwen column covers every harness generated from the Anthropic-compatible **p
2121
`src/engines/qwen.js` — today `qwen` (QwenCloud) and `qwen-eu` (Alibaba Cloud Model Studio, EU
2222
region). They share this column because they share the CLI and every guarantee in it; what differs
2323
per entry is the endpoint, the credential, the shipped and discovered model catalog, and the
24-
gateway default model.
24+
gateway default model and effort.
2525

2626
Each is **opt-in**: a missing `engineEnabled` entry means OFF, it is terminal in the failover graph
2727
in both directions, and its provider credential (its own `…ApiKey` / `…BaseUrl` pair) is gateway

‎documentation/src/content/docs/configuration/model-defaults.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,16 @@ Open **Settings → Agent defaults → Engine & runtime**.
1515
| Engine (which CLI drives the bot) | Default engine; Claude is the fallback default when none is configured |
1616
| Default Claude model | Sets the model used by inherited Claude runs |
1717
| Default Codex model | Sets the model used by inherited Codex runs |
18+
| Default Claude / Codex effort | Sets reasoning effort when a conversation or thread does not override it |
19+
| Provider default model and effort | Independent defaults on each optional Qwen provider card |
1820
| Automatic failover between harnesses | Enables supported Claude–Codex recovery before work starts; defaults on |
1921
| How a failover happens | Automatic, or ask in a live Slack thread; automatic is the default |
2022

2123
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.
2224

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

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

4850
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.
4951

50-
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.
52+
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.
5153

5254
## Control who changes runtime
5355

‎public/app.js‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,14 +128,14 @@ function engineOptionsHtml({ includeDefault = false } = {}) {
128128
selectableEngines().map((m) => `<option value="${escapeHtml(m.id)}">${escapeHtml(m.label)}</option>`).join("");
129129
}
130130

131-
function syncEffortOptions({ engineSelect, modelSelect, effortSelect, label, value }) {
132-
const engine = effectiveEngine(engineSelect?.value || "");
131+
function syncEffortOptions({ engineSelect, modelSelect, effortSelect, label, value, engine: pinnedEngine, blankLabel = "default" }) {
132+
const engine = pinnedEngine || effectiveEngine(engineSelect?.value || "");
133133
const modelEfforts = MODEL_EFFORT_OPTIONS[engine]?.[modelSelect?.value || ""];
134134
const options = modelEfforts?.length
135135
? [["", "default"], ...modelEfforts.map((effort) => [effort, effort])]
136136
: EFFORT_OPTIONS[engine] || EFFORT_OPTIONS.claude;
137137
const current = value !== undefined ? value : effortSelect.value;
138-
effortSelect.innerHTML = options.map(([v, text]) => `<option value="${escapeHtml(v)}">${escapeHtml(text)}</option>`).join("");
138+
effortSelect.innerHTML = options.map(([v, text]) => `<option value="${escapeHtml(v)}">${escapeHtml(v ? text : blankLabel)}</option>`).join("");
139139
effortSelect.value = options.some(([v]) => v === current) ? current : "";
140140
if (label) label.textContent = `Effort (${ENGINE_MANIFESTS.find((m) => m.id === engine)?.label || engine})`;
141141
}
@@ -187,6 +187,12 @@ function syncModelOptions({ engineSelect, modelSelect, value, engine, blankLabel
187187
modelSelect.value = options.some(([v]) => v === current) ? current : "";
188188
}
189189

190+
function syncDefaultEffort(modelSelect, effortSelect, engine, value) {
191+
const sync = (next) => syncEffortOptions({ modelSelect, effortSelect, engine, value: next, blankLabel: "CLI default" });
192+
sync(value || "");
193+
modelSelect.onchange = () => sync(effortSelect.value);
194+
}
195+
190196
function addModelShortcutRow(name = "", target = {}) {
191197
const box = document.getElementById("model-shortcuts-editor");
192198
const row = document.createElement("div");
@@ -3769,6 +3775,7 @@ function paintQwenProviders(providers) {
37693775
const keyInput = at("api-key");
37703776
const urlInput = at("base-url");
37713777
const modelSelect = at("default-model");
3778+
const effortSelect = at("default-effort");
37723779
const clearBtn = at("clear-key");
37733780
// An endpoint the provider ships is a placeholder (blank = keep it); one only the operator can
37743781
// know is not, so the box says so instead of implying a default exists.
@@ -3779,6 +3786,7 @@ function paintQwenProviders(providers) {
37793786
// The button sits inside the field's <label>; preventDefault stops the click focusing the input.
37803787
clearBtn.addEventListener("click", (e) => { e.preventDefault(); toggleClearTok(clearBtn); });
37813788
syncModelOptions({ modelSelect, engine: p.id, value: p.defaultModel || "", blankLabel: "provider default" });
3789+
syncDefaultEffort(modelSelect, effortSelect, p.id, p.defaultEffort);
37823790
// Say WHERE the list came from: a stale shipped list and a live account list look identical in
37833791
// a picker, and the difference decides whether a missing model is a typo or a missing key.
37843792
const catalog = ENGINE_MANIFESTS.find((m) => m.id === p.id)?.modelCatalog;
@@ -3788,7 +3796,7 @@ function paintQwenProviders(providers) {
37883796
? "Built-in list — the account's own list could not be read. Check the key and endpoint, then reload."
37893797
: "Built-in list. Save an API key to read the account's own models.");
37903798
tpl.parentNode.insertBefore(card, tpl);
3791-
QWEN_PROVIDER_CARDS.push({ ...p, keyInput, urlInput, modelSelect, clearBtn });
3799+
QWEN_PROVIDER_CARDS.push({ ...p, keyInput, urlInput, modelSelect, effortSelect, clearBtn });
37923800
}
37933801
}
37943802

@@ -3799,6 +3807,7 @@ function collectQwenProviders() {
37993807
for (const card of QWEN_PROVIDER_CARDS) {
38003808
body[card.baseUrlField] = card.urlInput.value;
38013809
body[card.defaultModelField] = card.modelSelect.value;
3810+
body[card.defaultEffortField] = card.effortSelect.value;
38023811
const typed = tokenValue(card.keyInput);
38033812
if (typed) body[card.apiKeyField] = typed;
38043813
if (card.clearBtn.classList.contains("armed")) body[`clear${card.apiKeyField[0].toUpperCase()}${card.apiKeyField.slice(1)}`] = true;
@@ -3905,6 +3914,8 @@ function readSettingsForm() {
39053914
engine: document.getElementById("set-engine").value,
39063915
defaultClaudeModel: document.getElementById("set-default-claude-model").value,
39073916
defaultCodexModel: document.getElementById("set-default-codex-model").value,
3917+
defaultClaudeEffort: document.getElementById("set-default-claude-effort").value,
3918+
defaultCodexEffort: document.getElementById("set-default-codex-effort").value,
39083919
...collectQwenProviders(),
39093920
perplexityResearchEnabled: document.getElementById("set-perplexity-research-enabled").checked,
39103921
...(document.getElementById("set-perplexity-session-token").value ? { perplexitySessionToken: document.getElementById("set-perplexity-session-token").value } : {}),
@@ -4052,6 +4063,9 @@ function paintSettings(s) {
40524063
GLOBAL_ENGINE = s.engine || "claude";
40534064
syncModelOptions({ modelSelect: document.getElementById("set-default-claude-model"), engine: "claude", value: s.defaultClaudeModel || "", blankLabel: "CLI default" });
40544065
syncModelOptions({ modelSelect: document.getElementById("set-default-codex-model"), engine: "codex", value: s.defaultCodexModel || "", blankLabel: "CLI default" });
4066+
for (const engine of ["claude", "codex"]) {
4067+
syncDefaultEffort(document.getElementById(`set-default-${engine}-model`), document.getElementById(`set-default-${engine}-effort`), engine, s[`default${engine === "claude" ? "Claude" : "Codex"}Effort`]);
4068+
}
40554069
document.getElementById("set-model-change-access").value = s.modelChangeAccess || "admins";
40564070
paintModelShortcuts(s.modelShortcuts);
40574071
document.getElementById("set-engine-fallback").checked = s.engineFallback !== false;

‎public/index.html‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,10 +586,20 @@ <h4>Engine &amp; runtime</h4>
586586
<span>Default Claude model — pins gateway runs so the terminal's <code>/model</code> choice never leaks in</span>
587587
<select id="set-default-claude-model"></select>
588588
</label>
589+
<label class="field">
590+
<span>Default Claude effort</span>
591+
<select id="set-default-claude-effort"></select>
592+
<em class="state">Used when no thread or conversation effort is set. Blank uses the CLI default.</em>
593+
</label>
589594
<label class="field">
590595
<span>Default Codex model</span>
591596
<select id="set-default-codex-model"></select>
592597
</label>
598+
<label class="field">
599+
<span>Default Codex effort</span>
600+
<select id="set-default-codex-effort"></select>
601+
<em class="state">Used when no thread or conversation effort is set. Blank uses the CLI default.</em>
602+
</label>
593603
<div class="ch-codex-login" id="gateway-codex-login">
594604
<strong>Default gateway Codex login</strong>
595605
<p class="desc">Shared by channels that use the gateway login.</p>
@@ -661,6 +671,11 @@ <h4 data-role="label"></h4>
661671
<select data-role="default-model"></select>
662672
<em class="state" data-role="catalog-note"></em>
663673
</label>
674+
<label class="field">
675+
<span>Default effort</span>
676+
<select data-role="default-effort"></select>
677+
<em class="state">Used when no thread or conversation effort is set. Blank uses the CLI default.</em>
678+
</label>
664679
</div>
665680
</template>
666681
<div class="setcard">

‎src/config/settings.js‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,14 @@ export function getDefaultModel(engine) {
334334
return typeof v === "string" ? v.trim() : "";
335335
}
336336

337+
// Blank preserves the CLI default; stale or hand-edited values never reach a runner.
338+
export function getDefaultEffort(engine) {
339+
const adapter = adapterOr(engine);
340+
const raw = getSettings()[adapter.defaultEffortKey];
341+
const effort = typeof raw === "string" ? raw.trim().toLowerCase() : "";
342+
return adapter.efforts.includes(effort) ? effort : "";
343+
}
344+
337345
export function getModelShortcuts() {
338346
return modelShortcutsFromSettings(getSettings(), { claude: modelsForEngine("claude"), codex: modelsForEngine("codex") });
339347
}
@@ -493,12 +501,14 @@ export function qwenProviderSettings() {
493501
apiKeyField: entry.settings.apiKey,
494502
baseUrlField: entry.settings.baseUrl,
495503
defaultModelField: entry.defaultModelKey,
504+
defaultEffortField: entry.defaultEffortKey,
496505
hasApiKey: Boolean(config.apiKey),
497506
apiKeyLast4: last4(config.apiKey),
498507
// The STORED endpoint, so an empty box means "still on the shipped default" rather than
499508
// silently re-saving a default the operator never chose.
500509
baseUrl: typeof getSettings()[entry.settings.baseUrl] === "string" ? getSettings()[entry.settings.baseUrl].trim() : "",
501510
defaultModel: getDefaultModel(entry.id),
511+
defaultEffort: getDefaultEffort(entry.id),
502512
};
503513
});
504514
}
@@ -1153,6 +1163,7 @@ export function settingsForApi() {
11531163
engine: getEngine(),
11541164
defaultClaudeModel: getDefaultModel("claude"),
11551165
defaultCodexModel: getDefaultModel("codex"),
1166+
...Object.fromEntries(ENGINES.map((id) => [adapterOr(id).defaultEffortKey, getDefaultEffort(id)])),
11561167
// One entry per Anthropic-compatible provider, so the admin UI renders a card per harness
11571168
// instead of carrying a hard-coded copy of the table.
11581169
qwenProviders: qwenProviderSettings(),

‎src/engines/adapters.js‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ const baseCompile = (engine, request = {}, supportedModes = ["off"]) => {
5959
const claude = validateEngineAdapter({
6060
pluginCapabilities: { manifest: "claude", components: ["skills", "commands", "agents", "hooks", "mcpServers"] },
6161
resolveOptionalMcpConfig: resolveClaudeMcpConfig,
62-
id: "claude", label: "Claude", cli: "claude", defaultModelKey: "defaultClaudeModel", mcpMetaKey: "allowedMcps",
62+
id: "claude", label: "Claude", cli: "claude", defaultModelKey: "defaultClaudeModel", defaultEffortKey: "defaultClaudeEffort", mcpMetaKey: "allowedMcps",
6363
instructionFile: "CLAUDE.md", skillsDir: ".claude/skills", mcpTransport: "file", contextWindow: 200_000,
6464
efforts: ["low", "medium", "high", "xhigh"], models: [
6565
{ label: "Opus 5.5", value: "claude-opus-5-5", description: "Current Opus model for most coding and knowledge work." },
@@ -205,7 +205,7 @@ const claude = validateEngineAdapter({
205205
const makeQwenAdapter = (entry) => validateEngineAdapter({
206206
pluginCapabilities: { manifest: "claude", components: ["skills", "commands", "agents", "hooks", "mcpServers"] },
207207
resolveOptionalMcpConfig: resolveClaudeMcpConfig,
208-
id: entry.id, label: entry.label, cli: "claude", defaultModelKey: entry.defaultModelKey,
208+
id: entry.id, label: entry.label, cli: "claude", defaultModelKey: entry.defaultModelKey, defaultEffortKey: entry.defaultEffortKey,
209209
// The shared model-ID vocabulary these harnesses draw from (Qwen/GLM/DeepSeek/Kimi/MiniMax).
210210
// Declared so the admin UI can recognise a provider harness's model without a hard-coded engine
211211
// id — a provider added to the table would otherwise have its saved model silently dropped from
@@ -312,7 +312,7 @@ const codex = validateEngineAdapter({
312312
}
313313
return { servers, rejected };
314314
},
315-
id: "codex", label: "Codex", cli: "codex", defaultModelKey: "defaultCodexModel", mcpMetaKey: "allowedCodexMcps",
315+
id: "codex", label: "Codex", cli: "codex", defaultModelKey: "defaultCodexModel", defaultEffortKey: "defaultCodexEffort", mcpMetaKey: "allowedCodexMcps",
316316
// Codex reports no dollar cost, so the ledger prices it from this configured rate (Settings →
317317
// Audit). Declared here so no other engine inherits OpenAI's rates by falling through.
318318
costRateKey: "codexRatePer1MTokens",
@@ -375,7 +375,7 @@ const codex = validateEngineAdapter({
375375
});
376376

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

0 commit comments

Comments
 (0)