Release: centralize auxiliary-model task catalog (#5865) (#5870)

Auxiliary-model task definitions were maintained in BOTH Python and JS and could
drift (the retired session_search task lingered in one). Centralize to a single
backend catalog that panels.js renders+saves from exclusively; purge the retired
slot on reset while preserving other custom bindings; old configs still load.

Gate-pass: Codex SAFE (only session_search removed, 11 other tasks + defaults
unchanged, 22 keys match across 15 locales, legacy migrate preserves custom
bindings, executable DOM harness rendered all rows). 53 own + 887 regression green.

Co-authored-by: t <a@b>
Co-authored-by: starship-s <starship-s@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-07-09 20:31:54 -07:00
1 parent 9c1e77de48
commit de0e6eb452
5 files changed
+365 -145

No files matched your search

+2
View File
@@ -11,6 +11,8 @@
### Fixed
- **The auxiliary-model settings now come from a single catalog, so the task list can't drift.** The auxiliary/helper-model task definitions were maintained separately in Python and JavaScript, which let the two lists fall out of sync (and left the retired `session_search` task lingering in one). The settings panel now renders and saves exclusively from a single backend catalog; the retired task is purged on reset while every other custom binding is preserved, and existing configs referencing the old task still load cleanly. Thanks @starship-s. (#5865)
- **The Experimental update channel now shows the real installed version instead of "unknown" / a bare SHA on a stable-tagged install.** When an install pinned to a stable `v*` tag opts into the Experimental channel, no `exp-v*` tag sits behind HEAD, so the channel-scoped version lookup returned nothing and the UI rendered "unknown" (and a bogus ahead-count of 1). The check now recovers the real installed version and the true number of Experimental releases ahead. The installed-version fallback is scoped to the WebUI repo (never injected into the Agent repo's payload), and the compare-link ref is always a git-verified tag (or omitted) rather than a possibly-dirty version string. Thanks @nesquena-hermes. (#5864, #5862)
- **The activity worklog no longer freezes the browser on reasoning-heavy history (#4633 frontend half).** A settled turn's activity worklog (the reasoning + tool steps behind the "Processed Xm Ys" chip) can carry 80+ rows; in compact-worklog mode (the default) every collapsed group still eagerly built all its row DOM, so a long history ballooned the DOM and a later synchronous layout could tip the tab into a multi-GB freeze. Collapsed settled worklogs now defer their row DOM until first expand. Expanded rows still get full post-processing (syntax highlighting, copy buttons, Mermaid, KaTeX, structured trees) and their open/scroll state is preserved across transcript rebuilds — matching (and, for nested-card scroll, improving on) the eager path. Thanks @nesquena-hermes. (#5860, #5839, #4633)
+63 -40
View File
@@ -4144,23 +4144,66 @@ def set_hermes_default_model(model_id: str, provider: str | None = None, advance
# ── Auxiliary model configuration ──────────────────────────────────────────
# Canonical auxiliary task slots. Keep in sync with hermes_cli/config.py
# DEFAULT_CONFIG["auxiliary"] and hermes_cli/web_server.py _AUX_TASK_SLOTS.
AUX_TASK_SLOTS: tuple[str, ...] = (
"vision",
"web_extract",
"compression",
"session_search",
"skills_hub",
"approval",
"mcp",
"title_generation",
"curator",
"kanban_decomposer",
"profile_describer",
"triage_specifier",
# Canonical auxiliary task catalog.
# Keep in sync with hermes_cli/config.py DEFAULT_CONFIG["auxiliary"] and
# hermes_cli/web_server.py _AUX_TASK_SLOTS.
AUXILIARY_TASK_CATALOG: tuple[dict[str, str], ...] = (
{"key": "vision", "label": "Vision", "description": "image/screenshot analysis"},
{"key": "web_extract", "label": "Web extract", "description": "web page summarization"},
{"key": "compression", "label": "Compression", "description": "context summarization"},
{"key": "approval", "label": "Approval", "description": "smart command approval"},
{"key": "mcp", "label": "MCP", "description": "MCP tool reasoning"},
{"key": "title_generation", "label": "Title generation", "description": "session titles"},
{"key": "skills_hub", "label": "Skills hub", "description": "skills search/install"},
{"key": "curator", "label": "Curator", "description": "skill-usage review pass"},
{"key": "kanban_decomposer", "label": "Kanban decomposer", "description": "task decomposition"},
{"key": "profile_describer", "label": "Profile describer", "description": "profile summaries"},
{"key": "triage_specifier", "label": "Triage specifier", "description": "issue/task triage specs"},
)
AUX_TASK_SLOTS: tuple[str, ...] = tuple(item["key"] for item in AUXILIARY_TASK_CATALOG)
# Slots removed from the WebUI catalog whose persisted assignments should be
# discarded when the user explicitly resets all auxiliary-model routing.
RETIRED_AUX_TASK_SLOTS: tuple[str, ...] = ("session_search",)
def _aux_task_payload(task_key: str, entry: dict, fallback_label: str = "", fallback_description: str = "") -> dict:
"""Build the API payload row for a single auxiliary task."""
if not isinstance(entry, dict):
entry = {}
return {
"task": task_key,
"provider": str(entry.get("provider") or "auto").strip() or "auto",
"model": str(entry.get("model") or "").strip(),
"base_url": str(entry.get("base_url") or "").strip(),
"timeout": entry.get("timeout", ""),
"download_timeout": entry.get("download_timeout", ""),
"max_concurrency": entry.get("max_concurrency", ""),
"extra_body": entry.get("extra_body") if isinstance(entry.get("extra_body"), dict) else {},
"api_key_set": bool(str(entry.get("api_key") or "").strip()),
"label": fallback_label,
"description": fallback_description,
}
def _iter_auxiliary_task_rows() -> list[dict]:
"""Return canonical auxiliary task payload rows."""
aux_cfg = cfg.get("auxiliary", {})
if not isinstance(aux_cfg, dict):
aux_cfg = {}
rows: list[dict] = []
# Canonical, first-class tasks from WebUI's catalog.
for slot in AUXILIARY_TASK_CATALOG:
key = str(slot["key"]).strip()
if not key:
continue
rows.append(_aux_task_payload(key, aux_cfg.get(key, {}), slot["label"], slot["description"]))
return rows
def get_auxiliary_models() -> dict:
"""Return current auxiliary task assignments from config.yaml.
@@ -4181,26 +4224,7 @@ def get_auxiliary_models() -> dict:
main_provider = str(model_cfg.get("provider") or "").strip()
main_model = str(model_cfg.get("default") or model_cfg.get("name") or "").strip()
aux_cfg = cfg.get("auxiliary", {})
if not isinstance(aux_cfg, dict):
aux_cfg = {}
tasks = []
for slot in AUX_TASK_SLOTS:
entry = aux_cfg.get(slot, {})
if not isinstance(entry, dict):
entry = {}
tasks.append({
"task": slot,
"provider": str(entry.get("provider") or "auto").strip(),
"model": str(entry.get("model") or "").strip(),
"base_url": str(entry.get("base_url") or "").strip(),
"timeout": entry.get("timeout", ""),
"download_timeout": entry.get("download_timeout", ""),
"max_concurrency": entry.get("max_concurrency", ""),
"extra_body": entry.get("extra_body") if isinstance(entry.get("extra_body"), dict) else {},
"api_key_set": bool(str(entry.get("api_key") or "").strip()),
})
tasks = _iter_auxiliary_task_rows()
return {
"tasks": tasks,
@@ -4238,20 +4262,19 @@ def set_auxiliary_model(task: str, provider: str, model: str, advanced: dict | N
Sensitive api_key values are write-only: get_auxiliary_models() only reports
whether one is set.
"""
if task != "__reset__" and task not in AUX_TASK_SLOTS:
raise ValueError(
f"Unknown auxiliary task slot: {task!r}. Valid: {list(AUX_TASK_SLOTS)}"
)
config_path = _get_config_path()
with _cfg_lock:
config_data = _load_yaml_config_file(config_path)
if task != "__reset__" and task not in AUX_TASK_SLOTS:
raise ValueError(f"Unknown auxiliary task slot: {task!r}. Valid: {list(AUX_TASK_SLOTS)}")
if task == "__reset__":
# Per-slot reset: set each slot to auto, preserving extra fields
# (timeout, extra_body, api_key, base_url, download_timeout, etc.)
aux_cfg = config_data.get("auxiliary", {})
if not isinstance(aux_cfg, dict):
aux_cfg = {}
for retired_slot in RETIRED_AUX_TASK_SLOTS:
aux_cfg.pop(retired_slot, None)
for slot in AUX_TASK_SLOTS:
slot_cfg = aux_cfg.get(slot, {})
if not isinstance(slot_cfg, dict):
-30
View File
@@ -772,8 +772,6 @@ const LOCALES = {
settings_aux_task_compression_desc: 'context summarization',
settings_aux_task_web_extract: 'Web extract',
settings_aux_task_web_extract_desc: 'web page summarization',
settings_aux_task_session_search: 'Session search',
settings_aux_task_session_search_desc: 'past-conversation recall',
settings_aux_task_approval: 'Approval',
settings_aux_task_approval_desc: 'smart command approval',
settings_aux_task_mcp: 'MCP',
@@ -2475,8 +2473,6 @@ const LOCALES = {
settings_aux_task_compression_desc: 'context summarization',
settings_aux_task_web_extract: 'Web extract',
settings_aux_task_web_extract_desc: 'web page summarization',
settings_aux_task_session_search: 'Session search',
settings_aux_task_session_search_desc: 'past-conversation recall',
settings_aux_task_approval: 'Approval',
settings_aux_task_approval_desc: 'smart command approval',
settings_aux_task_mcp: 'MCP',
@@ -4169,8 +4165,6 @@ const LOCALES = {
settings_aux_task_compression_desc: 'コンテキストの要約・圧縮',
settings_aux_task_web_extract: 'ウェブページ抽出',
settings_aux_task_web_extract_desc: 'ウェブページの要約',
settings_aux_task_session_search: 'セッション検索',
settings_aux_task_session_search_desc: '過去の会話の呼び出し',
settings_aux_task_approval: '承認',
settings_aux_task_approval_desc: 'コマンド実行のスマート承認',
settings_aux_task_mcp: 'MCP',
@@ -5581,8 +5575,6 @@ const LOCALES = {
settings_aux_task_compression_desc: 'context summarization',
settings_aux_task_web_extract: 'Web extract',
settings_aux_task_web_extract_desc: 'web page summarization',
settings_aux_task_session_search: 'Session search',
settings_aux_task_session_search_desc: 'past-conversation recall',
settings_aux_task_approval: 'Approval',
settings_aux_task_approval_desc: 'smart command approval',
settings_aux_task_mcp: 'MCP',
@@ -7192,8 +7184,6 @@ const LOCALES = {
settings_aux_task_compression_desc: 'context summarization',
settings_aux_task_web_extract: 'Web extract',
settings_aux_task_web_extract_desc: 'web page summarization',
settings_aux_task_session_search: 'Session search',
settings_aux_task_session_search_desc: 'past-conversation recall',
settings_aux_task_approval: 'Approval',
settings_aux_task_approval_desc: 'smart command approval',
settings_aux_task_mcp: 'MCP',
@@ -8798,8 +8788,6 @@ const LOCALES = {
settings_aux_task_compression_desc: 'Kontext-Zusammenfassung',
settings_aux_task_web_extract: 'Web-Extraktion',
settings_aux_task_web_extract_desc: 'Webseiten-Zusammenfassung',
settings_aux_task_session_search: 'Sitzungssuche',
settings_aux_task_session_search_desc: 'Erinnerung an frühere Gespräche',
settings_aux_task_approval: 'Freigabe',
settings_aux_task_approval_desc: 'intelligente Befehlsfreigabe',
settings_aux_task_mcp: 'MCP',
@@ -10475,8 +10463,6 @@ const LOCALES = {
settings_aux_task_compression_desc: '上下文摘要',
settings_aux_task_web_extract: '网页提取',
settings_aux_task_web_extract_desc: '网页内容摘要',
settings_aux_task_session_search: '会话搜索',
settings_aux_task_session_search_desc: '历史对话召回',
settings_aux_task_approval: '批准',
settings_aux_task_approval_desc: '智能指令批准',
settings_aux_task_mcp: 'MCP',
@@ -12389,8 +12375,6 @@ const LOCALES = {
settings_aux_task_compression_desc: 'context summarization',
settings_aux_task_web_extract: 'Web extract',
settings_aux_task_web_extract_desc: 'web page summarization',
settings_aux_task_session_search: 'Session search',
settings_aux_task_session_search_desc: 'past-conversation recall',
settings_aux_task_approval: 'Approval',
settings_aux_task_approval_desc: 'smart command approval',
settings_aux_task_mcp: 'MCP',
@@ -13979,8 +13963,6 @@ const LOCALES = {
settings_aux_task_compression_desc: 'context summarization',
settings_aux_task_web_extract: 'Web extract',
settings_aux_task_web_extract_desc: 'web page summarization',
settings_aux_task_session_search: 'Session search',
settings_aux_task_session_search_desc: 'past-conversation recall',
settings_aux_task_approval: 'Approval',
settings_aux_task_approval_desc: 'smart command approval',
settings_aux_task_mcp: 'MCP',
@@ -15572,8 +15554,6 @@ const LOCALES = {
settings_aux_task_compression_desc: 'context summarization',
settings_aux_task_web_extract: 'Web extract',
settings_aux_task_web_extract_desc: 'web page summarization',
settings_aux_task_session_search: 'Session search',
settings_aux_task_session_search_desc: 'past-conversation recall',
settings_aux_task_approval: 'Approval',
settings_aux_task_approval_desc: 'smart command approval',
settings_aux_task_mcp: 'MCP',
@@ -17278,8 +17258,6 @@ const LOCALES = {
settings_aux_task_compression_desc: 'résumé du contexte',
settings_aux_task_web_extract: 'Extraction web',
settings_aux_task_web_extract_desc: 'résumé de page web',
settings_aux_task_session_search: 'Recherche de session',
settings_aux_task_session_search_desc: 'rappel de conversations passées',
settings_aux_task_approval: 'Approbation',
settings_aux_task_approval_desc: 'approbation intelligente des commandes',
settings_aux_task_mcp: 'MCP',
@@ -19043,8 +19021,6 @@ const LOCALES = {
settings_aux_task_mcp_desc: 'uvažování o nástrojích MCP',
settings_aux_task_profile_describer: 'Profil popisovač',
settings_aux_task_profile_describer_desc: 'souhrny profilů',
settings_aux_task_session_search: 'Hledání relací',
settings_aux_task_session_search_desc: 'připomenutí minulých konverzací',
settings_aux_task_skills_hub: 'Centrum dovedností',
settings_aux_task_skills_hub_desc: 'hledání/instalace dovedností',
settings_aux_task_title_generation: 'Generování názvů',
@@ -20576,8 +20552,6 @@ const LOCALES = {
settings_aux_task_compression_desc: 'context summarization',
settings_aux_task_web_extract: 'Web extract',
settings_aux_task_web_extract_desc: 'web page summarization',
settings_aux_task_session_search: 'Session search',
settings_aux_task_session_search_desc: 'past-conversation recall',
settings_aux_task_approval: 'Approval',
settings_aux_task_approval_desc: 'smart command approval',
settings_aux_task_mcp: 'MCP',
@@ -22292,8 +22266,6 @@ const LOCALES = {
settings_aux_task_compression_desc: 'streszczanie kontekstu',
settings_aux_task_web_extract: 'Ekstrakcja treści z sieci',
settings_aux_task_web_extract_desc: 'streszczanie stron internetowych',
settings_aux_task_session_search: 'Wyszukiwanie sesji',
settings_aux_task_session_search_desc: 'przywoływanie minionych konwersacji',
settings_aux_task_approval: 'Zatwierdzanie',
settings_aux_task_approval_desc: 'inteligentne zatwierdzanie poleceń',
settings_aux_task_mcp: 'MCP',
@@ -24855,8 +24827,6 @@ const LOCALES = {
settings_aux_task_compression_desc: 'tóm tắt ngữ cảnh',
settings_aux_task_web_extract: 'Web extract',
settings_aux_task_web_extract_desc: 'trích xuất và tóm tắt trang web',
settings_aux_task_session_search: 'Tìm phiên',
settings_aux_task_session_search_desc: 'truy hồi hội thoại cũ',
settings_aux_task_approval: 'Approval',
settings_aux_task_approval_desc: 'phê duyệt lệnh thông minh',
settings_aux_task_mcp: 'MCP',
+88 -59
View File
@@ -11897,24 +11897,8 @@ async function checkUpdatesNow(channelOverride){
// ── Auxiliary Models ──────────────────────────────────────────────────────────
// Canonical auxiliary task slots with display names.
// Keep in sync with hermes_cli/main.py _AUX_TASKS and hermes_cli/web_server.py _AUX_TASK_SLOTS.
const _AUX_TASK_SLOTS=[
{key:'vision',nameKey:'settings_aux_task_vision',descKey:'settings_aux_task_vision_desc'},
{key:'compression',nameKey:'settings_aux_task_compression',descKey:'settings_aux_task_compression_desc'},
{key:'web_extract',nameKey:'settings_aux_task_web_extract',descKey:'settings_aux_task_web_extract_desc'},
{key:'session_search',nameKey:'settings_aux_task_session_search',descKey:'settings_aux_task_session_search_desc'},
{key:'approval',nameKey:'settings_aux_task_approval',descKey:'settings_aux_task_approval_desc'},
{key:'mcp',nameKey:'settings_aux_task_mcp',descKey:'settings_aux_task_mcp_desc'},
{key:'title_generation',nameKey:'settings_aux_task_title_generation',descKey:'settings_aux_task_title_generation_desc'},
{key:'skills_hub',nameKey:'settings_aux_task_skills_hub',descKey:'settings_aux_task_skills_hub_desc'},
{key:'curator',nameKey:'settings_aux_task_curator',descKey:'settings_aux_task_curator_desc'},
{key:'kanban_decomposer',nameKey:'settings_aux_task_kanban_decomposer',descKey:'settings_aux_task_kanban_decomposer_desc'},
{key:'profile_describer',nameKey:'settings_aux_task_profile_describer',descKey:'settings_aux_task_profile_describer_desc'},
{key:'triage_specifier',nameKey:'settings_aux_task_triage_specifier',descKey:'settings_aux_task_triage_specifier_desc'},
];
let _auxProviders=[]; // cached provider list from /api/models
let _auxTasks=[]; // sanitized auxiliary task configs from /api/model/auxiliary
let _auxOriginalConfig=null; // snapshot of initial config for dirty detection
let _mainAdvancedConfig=null; // current advanced config for the default chat model
@@ -11922,6 +11906,50 @@ function _auxSelectStyle(){
return 'width:100%;padding:6px 8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:12px;box-sizing:border-box';
}
function _auxTaskLabelFromMeta(taskKey, taskCfg){
const nameKey='settings_aux_task_'+taskKey;
const descKey=nameKey+'_desc';
const tName=t(nameKey);
const tDesc=t(descKey);
const name=(tName&&tName!==nameKey)?String(tName).trim():'';
const description=(tDesc&&tDesc!==descKey)?String(tDesc).trim():'';
const fallbackName=(taskCfg&&typeof taskCfg.label==='string'&&taskCfg.label.trim())?String(taskCfg.label).trim():taskKey;
const fallbackDesc=(taskCfg&&typeof taskCfg.description==='string'&&taskCfg.description.trim())?String(taskCfg.description).trim():'';
return {
task: taskKey,
label: name||fallbackName,
description: description||fallbackDesc,
};
}
function _normalizeAuxiliaryTasks(rawTasks){
const tasks=Array.isArray(rawTasks)?rawTasks:[];
const out=[];
const seen=new Set();
for(const rawTask of tasks){
if(!rawTask||typeof rawTask!=='object') continue;
const task=(typeof rawTask.task==='string'?String(rawTask.task).trim():'');
if(!task||seen.has(task)) continue;
seen.add(task);
const meta=_auxTaskLabelFromMeta(task,rawTask);
const entry={
task,
provider:String(rawTask.provider||'auto').trim()||'auto',
model:String(rawTask.model||'').trim(),
base_url:String(rawTask.base_url||'').trim(),
timeout:rawTask.timeout,
download_timeout:rawTask.download_timeout,
max_concurrency:rawTask.max_concurrency,
extra_body:rawTask.extra_body&&typeof rawTask.extra_body==='object'?rawTask.extra_body:{},
api_key_set:!!rawTask.api_key_set,
label:meta.label,
description:meta.description,
};
out.push(entry);
}
return out;
}
function _buildAuxProviderOptions(sel,providers,currentProvider){
sel.innerHTML='';
// "auto" = use main model
@@ -12055,13 +12083,14 @@ function _mainModelSupportsServiceTier(cfg){
return cfg&&cfg.supports_fast_tier===true;
}
function _openAuxAdvancedOptions(taskKey,cfg){
const isMain=taskKey==='__main__';
const slot=isMain?{key:taskKey,nameKey:'settings_label_model',descKey:'settings_desc_model'}:(_AUX_TASK_SLOTS.find(s=>s.key===taskKey)||{key:taskKey,nameKey:'',descKey:''});
function _openAuxAdvancedOptions(taskCfg,cfg){
const isMain=taskCfg==='__main__';
const taskKey=isMain?'__main__':(taskCfg&&typeof taskCfg==='object'&&typeof taskCfg.task==='string'?taskCfg.task:typeof taskCfg==='string'?taskCfg:'');
const slot=isMain?{task:taskKey,label:(t('settings_label_model')||'Default model')}:_auxTaskLabelFromMeta(taskKey,taskCfg);
const overlay=_ensureAuxAdvancedModal();
overlay.dataset.task=taskKey;
const title=$('auxAdvancedTitle'),sub=$('auxAdvancedSubtitle'),body=$('auxAdvancedBody');
const slotName=t(slot.nameKey)||slot.key;
const slotName=isMain?(t('settings_label_model')||'Default model'):(slot&&slot.label)||taskKey;
if(title) title.textContent=isMain?(t('settings_main_advanced_title')||'Main model options'):((t('settings_aux_advanced_title')||'{task} options').replace('{task}',slotName));
if(sub) sub.textContent=isMain?(t('settings_main_advanced_subtitle')||'Advanced config for the default chat model.'):(t('settings_aux_advanced_subtitle')||'Advanced config for auxiliary.');
const extraBody=cfg&&cfg.extra_body&&typeof cfg.extra_body==='object'&&Object.keys(cfg.extra_body).length?JSON.stringify(cfg.extra_body,null,2):'';
@@ -12185,68 +12214,68 @@ async function _loadAuxiliaryModels(){
container.innerHTML='<div style="color:var(--muted);font-size:12px">'+(t('settings_aux_loading')||'Loading…')+'</div>';
try{
// Fetch auxiliary config AND the WebUI's own /api/models for provider/model lists
const [auxData,modelsData]=await Promise.all([
api('/api/model/auxiliary').catch(()=>null),
api('/api/models').catch(()=>null),
]);
// Build provider list from /api/models groups
// /api/models returns: { groups: [{ provider: str, provider_id: str, models: [{id,label}] }] }
const groups=(modelsData&&modelsData.groups)||[];
_auxProviders=groups.filter(g=>g.provider&&((g.models&&g.models.length>0)||(g.extra_models&&g.extra_models.length>0))).map(g=>({
slug:g.provider_id||g.provider,
name:g.provider,
models:[...(g.models||[]),...(g.extra_models||[])].map(m=>m.id),
}));
if(auxData&&Object.prototype.hasOwnProperty.call(auxData,'main')){
_mainAdvancedConfig=auxData.main||{};
}else{
_mainAdvancedConfig=null;
}
_bindMainAdvancedOptionsButton();
const tasks=(auxData&&auxData.tasks)||[];
// Build a quick lookup: taskKey → {provider, model}
// Fetch auxiliary config AND the WebUI's own /api/models for provider/model lists
const [auxData,modelsData]=await Promise.all([
api('/api/model/auxiliary').catch(()=>null),
api('/api/models').catch(()=>null),
]);
// Build provider list from /api/models groups
// /api/models returns: { groups: [{ provider: str, provider_id: str, models: [{id,label}] }] }
const groups=(modelsData&&modelsData.groups)||[];
_auxProviders=groups.filter(g=>g.provider&&((g.models&&g.models.length>0)||(g.extra_models&&g.extra_models.length>0))).map(g=>({
slug:g.provider_id||g.provider,
name:g.provider,
models:[...(g.models||[]),...(g.extra_models||[])].map(m=>m.id),
}));
if(auxData&&Object.prototype.hasOwnProperty.call(auxData,'main')){
_mainAdvancedConfig=auxData.main||{};
}else{
_mainAdvancedConfig=null;
}
_bindMainAdvancedOptionsButton();
_auxTasks=_normalizeAuxiliaryTasks((auxData&&auxData.tasks)||[]);
// Build a quick lookup: taskKey → config
const taskMap={};
for(const t of tasks) taskMap[t.task]=t;
for(const task of _auxTasks) taskMap[task.task]=task;
_auxOriginalConfig=JSON.parse(JSON.stringify(taskMap));
container.innerHTML='';
for(const slot of _AUX_TASK_SLOTS){
const cfg=taskMap[slot.key]||{provider:'auto',model:''};
for(const task of _auxTasks){
const cfg=taskMap[task.task]||{provider:'auto',model:''};
const row=document.createElement('div');
row.style.cssText='display:grid;grid-template-columns:120px 1fr 1fr 34px;gap:8px;align-items:center;margin-bottom:8px';
// Task name + description
const label=document.createElement('div');
label.style.cssText='font-size:12px;font-weight:500;color:var(--text);line-height:1.3';
label.innerHTML=esc(t(slot.nameKey)||slot.key)+'<div style="font-size:10px;color:var(--muted);font-weight:400">'+esc(t(slot.descKey)||'')+'</div>';
label.innerHTML=esc(task.label||task.task)+'<div style="font-size:10px;color:var(--muted);font-weight:400">'+esc(task.description||'')+'</div>';
row.appendChild(label);
// Provider select
const provSel=document.createElement('select');
provSel.id='aux-prov-'+slot.key;
provSel.id='aux-prov-'+task.task;
provSel.style.cssText=_auxSelectStyle();
_buildAuxProviderOptions(provSel,_auxProviders,cfg.provider);
provSel.addEventListener('change',()=>_onAuxProviderChange(slot.key,_auxProviders));
provSel.addEventListener('change',()=>_onAuxProviderChange(task.task,_auxProviders));
row.appendChild(provSel);
// Model select
const modelSel=document.createElement('select');
modelSel.id='aux-model-'+slot.key;
modelSel.id='aux-model-'+task.task;
modelSel.style.cssText=_auxSelectStyle();
_buildAuxModelOptions(modelSel,cfg.provider,_auxProviders,cfg.model);
modelSel.addEventListener('change',()=>_onAuxModelChange(slot.key));
modelSel.addEventListener('change',()=>_onAuxModelChange(task.task));
row.appendChild(modelSel);
const advancedBtn=document.createElement('button');
advancedBtn.type='button';
advancedBtn.className='aux-advanced-btn model-advanced-btn';
const advTitle=t('settings_aux_advanced_button_title')||'Advanced options';
const slotName=t(slot.nameKey)||slot.key;
const taskName=task.label||task.task;
advancedBtn.title=advTitle;
advancedBtn.setAttribute('aria-label',(t('settings_aux_advanced_button_aria')||'Advanced options for {task}').replace('{task}',slotName));
advancedBtn.setAttribute('aria-label',(t('settings_aux_advanced_button_aria')||'Advanced options for {task}').replace('{task}',taskName));
advancedBtn.innerHTML=typeof li==='function'?li('settings',15):'⚙';
advancedBtn.addEventListener('click',()=>_openAuxAdvancedOptions(slot.key,cfg));
advancedBtn.addEventListener('click',()=>_openAuxAdvancedOptions(task,cfg));
row.appendChild(advancedBtn);
container.appendChild(row);
@@ -12284,20 +12313,20 @@ async function _loadAuxiliaryModels(){
async function _applyAuxModels(){
let saved=0;
for(const slot of _AUX_TASK_SLOTS){
const provSel=$('aux-prov-'+slot.key);
const modelSel=$('aux-model-'+slot.key);
for(const task of _auxTasks){
const provSel=$('aux-prov-'+task.task);
const modelSel=$('aux-model-'+task.task);
if(!provSel) continue;
const provider=provSel.value;
const model=(modelSel&&modelSel.value!=='__custom__')?(modelSel.value||''):'';
const orig=_auxOriginalConfig?.[slot.key]||{provider:'auto',model:''};
const orig=_auxOriginalConfig?.[task.task]||{provider:'auto',model:''};
// Only save if changed
if(provider!==orig.provider||model!==orig.model){
try{
await api('/api/model/set',{method:'POST',body:JSON.stringify({scope:'auxiliary',task:slot.key,provider,model})});
await api('/api/model/set',{method:'POST',body:JSON.stringify({scope:'auxiliary',task:task.task,provider,model})});
saved++;
}catch(e){
console.warn('[settings] failed to save aux task',slot.key,e);
console.warn('[settings] failed to save aux task',task.task,e);
if(typeof showToast==='function') showToast(t('settings_aux_save_failed')||'Failed to save auxiliary model');
return;
}
+212 -16
View File
@@ -4,14 +4,22 @@ Verifies that the auxiliary models card is present in the settings HTML,
that the JS loading/saving logic is wired up, and that all locales have the
required i18n keys.
"""
import json
import shutil
import subprocess
from pathlib import Path
from types import SimpleNamespace
import pytest
ROOT = Path(__file__).parent.parent
PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
PANELS_JS_PATH = ROOT / "static" / "panels.js"
PANELS_JS = PANELS_JS_PATH.read_text(encoding="utf-8")
INDEX_HTML = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
STREAMING_PY = (ROOT / "api" / "streaming.py").read_text(encoding="utf-8")
NODE = shutil.which("node")
class TestAuxiliaryModelsHTML:
@@ -63,18 +71,102 @@ class TestAuxiliaryModelsJS:
"Missing _applyAuxModels() in panels.js"
)
def test_aux_task_slots_defined(self):
"""_AUX_TASK_SLOTS must list the 12 canonical task slots."""
assert "_AUX_TASK_SLOTS" in PANELS_JS, (
"Missing _AUX_TASK_SLOTS constant in panels.js"
def test_auxiliary_task_metadata_is_normalized(self):
"""Frontend should keep auxiliary task rows backed by normalized metadata."""
assert "let _auxTasks=[]" in PANELS_JS, "Missing _auxTasks cache in panels.js"
assert "function _normalizeAuxiliaryTasks" in PANELS_JS, (
"Missing _normalizeAuxiliaryTasks() in panels.js"
)
# Verify all 12 tasks are present
for key in ("vision", "compression", "web_extract", "session_search",
"approval", "mcp", "title_generation", "skills_hub", "curator",
"kanban_decomposer", "profile_describer", "triage_specifier"):
assert f"key:'{key}'" in PANELS_JS, (
f"Missing auxiliary task slot '{key}' in _AUX_TASK_SLOTS"
)
assert "function _auxTaskLabelFromMeta" in PANELS_JS, (
"Missing _auxTaskLabelFromMeta() in panels.js"
)
assert "_auxTasks=_normalizeAuxiliaryTasks((auxData&&auxData.tasks)||[])" in PANELS_JS, (
"Auxiliary load flow must normalize API task payload"
)
assert "for(const task of _auxTasks)" in PANELS_JS, (
"Auxiliary rows should be rendered from normalized API payload"
)
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_normalize_auxiliary_tasks_keeps_first_wins_and_unknown_metadata(self):
"""Normalization should keep first occurrence and preserve unknown metadata."""
script = r"""
const fs = require('fs');
const src = fs.readFileSync(process.argv[1], 'utf8');
function extract(name){
const re = new RegExp('function\\s+' + name + '\\s*\\(');
const start = src.search(re);
if(start < 0) throw new Error(name + ' not found');
let i = src.indexOf('{', start);
let depth = 0;
while(i < src.length){
const ch = src[i];
if(ch === '{') depth += 1;
else if(ch === '}') {
depth -= 1;
if(depth === 0){
break;
}
}
i += 1;
}
if(depth !== 0) throw new Error(name + ' parse failed');
return src.slice(start, i + 1);
}
global.t = (key) => {
const dict = {
settings_aux_task_vision: 'Vision',
settings_aux_task_vision_desc: 'image/screenshot analysis',
};
return Object.prototype.hasOwnProperty.call(dict, key) ? dict[key] : key;
};
eval(extract('_auxTaskLabelFromMeta'));
eval(extract('_normalizeAuxiliaryTasks'));
const normalized = _normalizeAuxiliaryTasks([
{task: 'vision', provider: 'openai', model: 'gpt-5.5', label: 'Backend Vision', description: 'backend desc'},
{task: 'future_task', provider: 'openai', model: 'future-1', label: 'Future Task', description: 'future tool'},
{task: 'vision', provider: 'openai', model: 'gpt-5.6'},
null,
{},
]);
const vision = normalized.find((entry) => entry.task === 'vision');
const futureTask = normalized.find((entry) => entry.task === 'future_task');
console.log(JSON.stringify({
tasks: normalized.map((entry) => entry.task),
visionLabel: vision ? vision.label : null,
visionDescription: vision ? vision.description : null,
futureLabel: futureTask ? futureTask.label : null,
futureDescription: futureTask ? futureTask.description : null,
}));
"""
proc = subprocess.run(
[NODE, "-e", script, str(PANELS_JS_PATH)],
capture_output=True,
text=True,
timeout=20,
)
assert proc.returncode == 0, f"node probe failed:\n{proc.stderr}"
result = json.loads(proc.stdout.strip().splitlines()[-1])
assert result["tasks"] == ["vision", "future_task"], (
"normalize should keep duplicate removal and payload order"
)
assert result["visionLabel"] == "Vision"
assert result["visionDescription"] == "image/screenshot analysis"
assert result["futureLabel"] == "Future Task"
assert result["futureDescription"] == "future tool"
def test_no_hardcoded_aux_task_slot_array(self):
"""Auxiliary rows must not come from a hardcoded task-slot list."""
assert "_AUX_TASK_SLOTS" not in PANELS_JS
assert "const _AUX_TASK_SLOTS" not in PANELS_JS
assert "session_search" not in PANELS_JS
def test_advanced_options_button_and_modal_wiring(self):
"""Each auxiliary row and the main model should expose gear-driven advanced config editing."""
@@ -104,6 +196,17 @@ class TestAuxiliaryModelsJS:
):
assert marker in PANELS_JS
def test_normalize_auxiliary_tasks_deduplicates_and_rejects_malformed(self):
"""Frontend normalization should keep only first occurrence and valid payload entries."""
assert "if(!rawTask||typeof rawTask!=='object') continue;" in PANELS_JS
assert "if(!task||seen.has(task)) continue;" in PANELS_JS
def test_auxiliary_load_uses_api_payload_order(self):
"""Row rendering should iterate exactly the normalized API payload order."""
idx = PANELS_JS.find("_auxTasks=_normalizeAuxiliaryTasks((auxData&&auxData.tasks)||[])")
assert idx >= 0
assert "for(const task of _auxTasks)" in PANELS_JS[idx:idx + 380]
def test_main_advanced_modal_hides_unsupported_timing_fields_but_keeps_request_body(self):
"""Main-model modal should not advertise timing knobs that the chat agent cannot apply."""
open_idx = PANELS_JS.find("function _openAuxAdvancedOptions")
@@ -302,8 +405,6 @@ class TestAuxiliaryModelsI18n:
"settings_aux_task_compression_desc",
"settings_aux_task_web_extract",
"settings_aux_task_web_extract_desc",
"settings_aux_task_session_search",
"settings_aux_task_session_search_desc",
"settings_aux_task_approval",
"settings_aux_task_approval_desc",
"settings_aux_task_mcp",
@@ -330,13 +431,18 @@ class TestAuxiliaryModelsI18n:
)
def test_all_locales_have_auxiliary_keys(self):
"""Count of each key should equal the number of locales (12 with Turkish)."""
"""Count of each key should equal the number of supported locales."""
for key in self.REQUIRED_KEYS:
count = I18N_JS.count(f"{key}:")
assert count == 15, (
f"i18n key '{key}' found {count} times — expected 14 (one per locale)"
f"i18n key '{key}' found {count} times — expected 15 (one per locale)"
)
def test_session_search_aux_task_i18n_keys_removed(self):
"""session_search auxiliary labels were retired from the canonical set."""
assert "settings_aux_task_session_search" not in I18N_JS
assert "settings_aux_task_session_search_desc" not in I18N_JS
class TestAuxiliaryModelsBackend:
"""WebUI backend must expose /api/model/auxiliary and /api/model/set."""
@@ -447,6 +553,96 @@ class TestAuxiliaryModelsBackend:
assert vision["api_key_set"] is True
assert "api_key" not in vision
def test_get_auxiliary_models_omits_session_search_and_includes_metadata(self, monkeypatch):
"""Backend payload should omit unknown keys and keep canonical metadata."""
from api import config
monkeypatch.setattr(config, "reload_config", lambda: None)
monkeypatch.setattr(config, "cfg", {
"model": {"provider": "openai", "default": "gpt-5.5"},
"auxiliary": {
"vision": {"provider": "openai", "model": "gpt-5.5"},
"future_task": {
"provider": "openai",
"model": "gpt-5.5",
"label": "Future Task",
"description": "future tool",
},
"monitor": {"provider": "openai", "model": "gpt-5.5"},
"tts_audio_tags": {"provider": "openai", "model": "gpt-5.5"},
},
})
data = config.get_auxiliary_models()
task_keys = [t["task"] for t in data["tasks"]]
assert task_keys == [
"vision",
"web_extract",
"compression",
"approval",
"mcp",
"title_generation",
"skills_hub",
"curator",
"kanban_decomposer",
"profile_describer",
"triage_specifier",
]
assert "session_search" not in task_keys
assert "future_task" not in task_keys
assert "monitor" not in task_keys
assert "tts_audio_tags" not in task_keys
vision = next(t for t in data["tasks"] if t["task"] == "vision")
assert vision["label"] == "Vision"
assert vision["description"] == "image/screenshot analysis"
def test_set_auxiliary_model_rejects_existing_unknown_task(self, monkeypatch, tmp_path):
"""Existing unknown keys must be rejected by the auxiliary setter."""
from api import config
config_path = tmp_path / "config.yaml"
config_path.write_text(
"auxiliary:\n future_task:\n provider: openai\n model: gpt-5.5\n",
encoding="utf-8",
)
monkeypatch.setattr(config, "_get_config_path", lambda: config_path)
monkeypatch.setattr(config, "reload_config", lambda: None)
with pytest.raises(ValueError, match="Unknown auxiliary task slot") as excinfo:
config.set_auxiliary_model("future_task", "openai", "gpt-5.6")
assert "future_task" in str(excinfo.value)
def test_reset_removes_retired_slot_but_preserves_other_unknown_mappings(
self, monkeypatch, tmp_path
):
"""Reset cleans known retired slots without treating config as task schema."""
from api import config
config_path = tmp_path / "config.yaml"
config_path.write_text(
"auxiliary:\n"
" vision:\n"
" provider: openai\n"
" model: gpt-5.5\n"
" session_search:\n"
" provider: openai\n"
" model: gpt-5.5\n"
" monitor:\n"
" provider: openai\n"
" model: gpt-5.5\n",
encoding="utf-8",
)
monkeypatch.setattr(config, "_get_config_path", lambda: config_path)
monkeypatch.setattr(config, "reload_config", lambda: None)
config.set_auxiliary_model("__reset__", "auto", "")
saved = config._load_yaml_config_file(config_path)["auxiliary"]
assert "session_search" not in saved
assert saved["monitor"] == {"provider": "openai", "model": "gpt-5.5"}
assert saved["vision"]["provider"] == "auto"
assert saved["vision"]["model"] == ""
def test_backend_surfaces_main_advanced_fields_without_api_key_value(self, monkeypatch):
"""Main model advanced fields should be visible, but API keys remain write-only."""
from api import config