fix: respect auxiliary title timeout for manual regenerate

This commit is contained in:
Stacey2911
2026-07-01 08:20:29 +10:00
parent df9c643a80
commit 6044eaa0cc
2 changed files with 275 additions and 1 deletions
+41 -1
View File
@@ -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;
@@ -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