From 6044eaa0cc12c360fb2cfcff98740e2cb81fe56f Mon Sep 17 00:00:00 2001 From: Stacey2911 Date: Wed, 1 Jul 2026 08:20:29 +1000 Subject: [PATCH] fix: respect auxiliary title timeout for manual regenerate --- static/sessions.js | 42 +++- tests/test_manual_title_regenerate_timeout.py | 234 ++++++++++++++++++ 2 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 tests/test_manual_title_regenerate_timeout.py diff --git a/static/sessions.js b/static/sessions.js index ec53094fb..3a629329a 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -248,6 +248,43 @@ const SESSION_ARCHIVE_SWIPE_THRESHOLD_PX = 128; const SESSION_DELETE_SWIPE_THRESHOLD_PX = 128; const SESSION_SWIPE_CANCEL_RATIO = 0.75; +function _manualTitleAuxConfigFromPayload(auxData){ + if(!auxData||typeof auxData!=='object'||Array.isArray(auxData)) return null; + if(auxData.title_generation&&typeof auxData.title_generation==='object') return auxData; + const tasks=Array.isArray(auxData.tasks)?auxData.tasks:null; + if(!tasks) return null; + const taskMap={}; + for(const task of tasks){ + if(task&&typeof task==='object'&&typeof task.task==='string'&&task.task){ + taskMap[task.task]=task; + } + } + if(!Object.keys(taskMap).length) return null; + return taskMap; +} + +async function _loadManualTitleAuxConfig(){ + try{ + const auxData=await api('/api/model/auxiliary',{retries:0,timeoutToast:false}); + return _manualTitleAuxConfigFromPayload(auxData); + }catch(_){ + return null; + } +} + +async function _manualTitleRegenerateTimeoutMs(){ + let cfg=null; + try{ + const auxConfig=await _loadManualTitleAuxConfig(); + cfg=auxConfig&&auxConfig.title_generation; + }catch(_){ + return null; + } + const timeoutSeconds=Number(cfg&&cfg.timeout); + if(!Number.isFinite(timeoutSeconds)||timeoutSeconds<=0) return null; + return Math.max(30000,Math.round((timeoutSeconds+5)*1000)); +} + function _formatSessionModelWithGateway(s){ if(!s||!s.model)return''; const routing=(typeof _latestGatewayRoutingForSession==='function')?_latestGatewayRoutingForSession(s):(s.gateway_routing||null); @@ -3878,7 +3915,10 @@ function _openSessionActionMenu(session, anchorEl){ closeSessionActionMenu(); try{ if(typeof showToast==='function') showToast(t('session_title_regenerating'), 1600); - const response=await api('/api/session/title/regenerate',{method:'POST',body:JSON.stringify({session_id:session.session_id})}); + const requestOpts={method:'POST',body:JSON.stringify({session_id:session.session_id})}; + const timeoutMs=await _manualTitleRegenerateTimeoutMs(); + if(timeoutMs) requestOpts.timeoutMs=timeoutMs; + const response=await api('/api/session/title/regenerate',requestOpts); const nextTitle=(response&&response.title)||(response&&response.session&&response.session.title)||''; if(nextTitle){ session.title=nextTitle; diff --git a/tests/test_manual_title_regenerate_timeout.py b/tests/test_manual_title_regenerate_timeout.py new file mode 100644 index 000000000..2bf2ddd5b --- /dev/null +++ b/tests/test_manual_title_regenerate_timeout.py @@ -0,0 +1,234 @@ +"""Frontend regressions for manual title-regeneration timeout sizing.""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8") +NODE = shutil.which("node") + + +def _title_regenerate_action_block() -> str: + marker = "t('session_title_regenerate')" + start = SESSIONS_JS.find(marker) + assert start >= 0, "manual title regenerate action not found" + end = SESSIONS_JS.find("if(!isExternalSession){", start) + assert end > start, "manual title regenerate action end marker not found" + return SESSIONS_JS[start:end] + + +def _title_timeout_support_block() -> str: + start = SESSIONS_JS.find("function _manualTitleAuxConfigFromPayload") + assert start >= 0, "manual title auxiliary config support not found" + end = SESSIONS_JS.find("function _formatSessionModelWithGateway", start) + assert end > start, "manual title auxiliary config support end marker not found" + return SESSIONS_JS[start:end] + + +def _run_script(script: str): + result = subprocess.run( + [NODE, "-e", script], + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stderr or result.stdout + return json.loads(result.stdout) + + +def _run_helper(setup: str = ""): + helper = _title_timeout_support_block() + script = f""" + (async()=>{{ + {setup} + {helper} + const result=await _manualTitleRegenerateTimeoutMs(); + process.stdout.write(JSON.stringify(result)); + }})().catch(err=>{{ + console.error(err&&err.stack?err.stack:err); + process.exit(1); + }}); + """ + return _run_script(script) + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_manual_title_regenerate_timeout_uses_fetched_positive_aux_timeout(): + assert _run_helper("async function api(){return {title_generation:{timeout:180}};}") == 185000 + assert _run_helper("async function api(){return {title_generation:{timeout:'31'}};}") == 36000 + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_manual_title_regenerate_timeout_falls_back_when_fetch_missing_or_invalid(): + assert _run_helper("async function api(){throw new Error('missing mock should fall back');}") is None + for value in ("undefined", "null", "''", "'abc'", "0", "-4", "Infinity", "NaN"): + setup = f"async function api(){{return {{title_generation:{{timeout:{value}}}}};}}" + assert _run_helper(setup) is None + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_manual_title_regenerate_timeout_adds_slack_and_enforces_minimum(): + assert _run_helper("async function api(){return {title_generation:{timeout:10}};}") == 30000 + assert _run_helper("async function api(){return {title_generation:{timeout:25.1}};}") == 30100 + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_manual_title_regenerate_timeout_fetches_aux_config_fresh_each_time(): + setup = """ + let calls=0; + async function api(path,opts){ + calls++; + if(path!=='/api/model/auxiliary') throw new Error('wrong path '+path); + if(!opts||opts.retries!==0||opts.timeoutToast!==false) throw new Error('wrong fetch options'); + return {tasks:[{task:'title_generation',timeout:180}]}; + } + """ + helper = _title_timeout_support_block() + script = f""" + (async()=>{{ + {setup} + {helper} + const first=await _manualTitleRegenerateTimeoutMs(); + const second=await _manualTitleRegenerateTimeoutMs(); + process.stdout.write(JSON.stringify({{first,second,calls}})); + }})().catch(err=>{{ + console.error(err&&err.stack?err.stack:err); + process.exit(1); + }}); + """ + assert _run_script(script) == {"first": 185000, "second": 185000, "calls": 2} + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_manual_title_regenerate_timeout_empty_tasks_normalizes_to_null(): + setup = """ + let calls=0; + async function api(){ + calls++; + return {tasks:[]}; + } + """ + helper = _title_timeout_support_block() + script = f""" + (async()=>{{ + {setup} + {helper} + const normalized=_manualTitleAuxConfigFromPayload({{tasks:[]}}); + const first=await _loadManualTitleAuxConfig(); + const second=await _loadManualTitleAuxConfig(); + process.stdout.write(JSON.stringify({{ + normalized, + first, + second, + calls + }})); + }})().catch(err=>{{ + console.error(err&&err.stack?err.stack:err); + process.exit(1); + }}); + """ + assert _run_script(script) == { + "normalized": None, + "first": None, + "second": None, + "calls": 2, + } + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_manual_title_regenerate_timeout_fetch_failure_falls_back_then_retries(): + setup = """ + let calls=0; + async function api(){ + calls++; + if(calls===1) throw new Error('network down'); + return {tasks:[{task:'title_generation',timeout:180}]}; + } + """ + helper = _title_timeout_support_block() + script = f""" + (async()=>{{ + {setup} + {helper} + const first=await _manualTitleRegenerateTimeoutMs(); + const second=await _manualTitleRegenerateTimeoutMs(); + process.stdout.write(JSON.stringify({{first,second,calls}})); + }})().catch(err=>{{ + console.error(err&&err.stack?err.stack:err); + process.exit(1); + }}); + """ + assert _run_script(script) == {"first": None, "second": 185000, "calls": 2} + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_manual_title_regenerate_timeout_uses_each_fetched_value_after_profile_switch(): + setup = """ + let calls=0; + async function api(path,opts){ + calls++; + if(path!=='/api/model/auxiliary') throw new Error('wrong path '+path); + if(!opts||opts.retries!==0||opts.timeoutToast!==false) throw new Error('wrong fetch options'); + if(calls===1) return {tasks:[{task:'title_generation',timeout:180}]}; + return {tasks:[{task:'title_generation',timeout:31}]}; + } + """ + helper = _title_timeout_support_block() + script = f""" + (async()=>{{ + {setup} + {helper} + const first=await _manualTitleRegenerateTimeoutMs(); + const second=await _manualTitleRegenerateTimeoutMs(); + process.stdout.write(JSON.stringify({{first,second,calls}})); + }})().catch(err=>{{ + console.error(err&&err.stack?err.stack:err); + process.exit(1); + }}); + """ + assert _run_script(script) == {"first": 185000, "second": 36000, "calls": 2} + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_manual_title_regenerate_timeout_invalid_fetched_timeout_falls_back_without_timeout(): + setup = """ + let calls=0; + async function api(){ + calls++; + return {tasks:[{task:'title_generation',timeout:'abc'}]}; + } + """ + helper = _title_timeout_support_block() + script = f""" + (async()=>{{ + {setup} + {helper} + const first=await _manualTitleRegenerateTimeoutMs(); + const second=await _manualTitleRegenerateTimeoutMs(); + process.stdout.write(JSON.stringify({{first,second,calls}})); + }})().catch(err=>{{ + console.error(err&&err.stack?err.stack:err); + process.exit(1); + }}); + """ + assert _run_script(script) == {"first": None, "second": None, "calls": 2} + + +def test_manual_title_regenerate_api_call_uses_computed_timeout_option(): + block = _title_regenerate_action_block() + request_pos = block.find("const requestOpts={method:'POST',body:JSON.stringify({session_id:session.session_id})};") + timeout_pos = block.find("const timeoutMs=await _manualTitleRegenerateTimeoutMs();") + assign_pos = block.find("if(timeoutMs) requestOpts.timeoutMs=timeoutMs;") + api_pos = block.find("api('/api/session/title/regenerate',requestOpts)") + + assert -1 not in (request_pos, timeout_pos, assign_pos, api_pos) + assert request_pos < timeout_pos < assign_pos < api_pos + assert "timeoutMs:0" not in block