feat(#2697): per-session reasoning effort overrides (derived-session propagation)

Clean rebase of rodboev's #5676 (rebase-first).

Co-authored-by: rodboev <rodboev@users.noreply.github.com>
This commit is contained in:
nesquena-hermes
2026-07-06 19:56:14 +00:00
parent 40ac0c6be2
commit 8a5eca82ad
10 changed files with 1398 additions and 58 deletions
+66 -19
View File
@@ -3633,25 +3633,34 @@ def get_reasoning_status(
model_id: str | None = None,
provider_id: str | None = None,
base_url: str | None = None,
session=None,
config_data: dict | None = None,
) -> dict:
"""Return current reasoning configuration from the active profile's
config.yaml the same source of truth the CLI reads from.
"""Return current reasoning configuration from the active or supplied profile config.
Keys:
- show_reasoning: bool from ``display.show_reasoning`` (default True)
- reasoning_effort: str from ``agent.reasoning_effort`` ('' = default)
- reasoning_effort: str effective effort after session/profile resolve
"""
config_data = _load_yaml_config_file(_get_config_path())
display_cfg = config_data.get("display") or {}
agent_cfg = config_data.get("agent") or {}
active_config = config_data if isinstance(config_data, dict) else _load_yaml_config_file(_get_config_path())
display_cfg = active_config.get("display") or {}
agent_cfg = active_config.get("agent") or {}
show_raw = display_cfg.get("show_reasoning") if isinstance(display_cfg, dict) else None
effort_raw = agent_cfg.get("reasoning_effort") if isinstance(agent_cfg, dict) else None
profile_effort_raw = agent_cfg.get("reasoning_effort") if isinstance(agent_cfg, dict) else None
session_effort_raw = getattr(session, "reasoning_effort", None) if session is not None else None
if session_effort_raw is not None:
session_effort_raw = str(session_effort_raw).strip().lower() or None
resolve_model = model_id
resolve_provider = provider_id
if session is not None:
if not resolve_model:
resolve_model = str(getattr(session, "model", "")).strip() or None
if not resolve_provider:
resolve_provider = str(getattr(session, "model_provider", "")).strip() or None
resolve_base_url = base_url
if not resolve_model:
model_cfg = config_data.get("model") or {}
model_cfg = active_config.get("model") or {}
if isinstance(model_cfg, dict):
resolve_model = str(model_cfg.get("default") or "").strip() or None
if not resolve_provider and model_cfg.get("provider"):
@@ -3664,15 +3673,24 @@ def get_reasoning_status(
provider_id=resolve_provider,
base_url=resolve_base_url,
)
profile_reasoning_effort = coerce_reasoning_effort_for_model(
str(profile_effort_raw or "").strip().lower(),
resolve_model,
provider_id=resolve_provider,
base_url=resolve_base_url,
)
effective_effort_raw = session_effort_raw if session_effort_raw is not None else profile_effort_raw
return {
# Match CLI default (True if unset in config.yaml)
"show_reasoning": bool(show_raw) if isinstance(show_raw, bool) else True,
# Report the COERCED effort (not the raw config value) so boot/status/chip
# read paths agree with what streaming actually sends — e.g. a stale
# `reasoning_effort: max` surfaces as `xhigh`, not the now-unsupported `max`.
# (Codex review of the drop-max alignment.)
"profile_reasoning_effort": profile_reasoning_effort,
"session_reasoning_effort": session_effort_raw,
"has_session_reasoning_override": session_effort_raw is not None,
"reasoning_scope": "session" if session_effort_raw is not None else "profile",
# Report the COERCED effective effort so boot/status/chip read paths
# agree with what streaming actually sends.
"reasoning_effort": coerce_reasoning_effort_for_model(
str(effort_raw or "").strip().lower(),
str(effective_effort_raw or "").strip().lower(),
resolve_model,
provider_id=resolve_provider,
base_url=resolve_base_url,
@@ -3779,6 +3797,18 @@ def set_reasoning_display(show: bool) -> dict:
return get_reasoning_status()
def _normalize_reasoning_effort_input(effort, *, allow_clear: bool = False) -> str | None:
raw = str(effort or "").strip().lower()
if not raw:
return None if allow_clear else ""
if raw == "none" or raw in VALID_REASONING_EFFORTS:
return raw
raise ValueError(
f"Unknown reasoning effort '{effort}'. "
f"Valid: none, {', '.join(VALID_REASONING_EFFORTS)}."
)
def set_reasoning_effort(
effort: str,
*,
@@ -3792,14 +3822,9 @@ def set_reasoning_effort(
(``none`` | ``minimal`` | ``low`` | ``medium`` | ``high`` | ``xhigh``).
Raises ``ValueError`` on an unrecognised level so callers can return 400.
"""
raw = str(effort or "").strip().lower()
raw = _normalize_reasoning_effort_input(effort)
if not raw:
raise ValueError("effort is required")
if raw != "none" and raw not in VALID_REASONING_EFFORTS:
raise ValueError(
f"Unknown reasoning effort '{effort}'. "
f"Valid: none, {', '.join(VALID_REASONING_EFFORTS)}."
)
config_path = _get_config_path()
with _cfg_lock:
config_data = _load_yaml_config_file(config_path)
@@ -3817,6 +3842,28 @@ def set_reasoning_effort(
)
def set_session_reasoning_effort(
session,
effort,
*,
model_id: str | None = None,
provider_id: str | None = None,
base_url: str | None = None,
) -> dict:
"""Persist a per-session ``reasoning_effort`` override to the session sidecar."""
if session is None:
raise ValueError("session is required")
raw = _normalize_reasoning_effort_input(effort, allow_clear=True)
session.reasoning_effort = raw
session.save()
return get_reasoning_status(
session=session,
model_id=model_id,
provider_id=provider_id,
base_url=base_url,
)
def _public_advanced_model_options(model_cfg: dict) -> dict:
"""Return write-only-safe advanced options from a model config block."""
if not isinstance(model_cfg, dict):
+33 -4
View File
@@ -101,8 +101,31 @@ def _gateway_use_runs_api_enabled(config_data=None, environ: dict[str, str] | No
return raw in ("1", "true", "yes", "on")
def _gateway_reasoning_effort_for_request(cfg, *, model=None, model_provider=None):
def _gateway_reasoning_effort_for_request(
cfg,
*,
model=None,
model_provider=None,
session=None,
base_url=None,
):
"""Read and coerce user-configured reasoning effort for a gateway request."""
try:
from api.config import get_reasoning_status
status = get_reasoning_status(
model_id=model,
provider_id=model_provider,
base_url=base_url,
session=session,
config_data=cfg,
)
if isinstance(status, dict):
effort = status.get("reasoning_effort")
if effort:
return str(effort)
except Exception:
pass
try:
cfg_data = cfg if isinstance(cfg, dict) else {}
effort_cfg = cfg_data.get("agent", {}) if isinstance(cfg_data, dict) else {}
@@ -571,13 +594,20 @@ def _run_gateway_chat_streaming(
usage = {"input_tokens": 0, "output_tokens": 0, "estimated_cost": 0}
try:
s = get_session(session_id)
from api.config import get_config # imported lazily to avoid config-cycle churn
from api.config import get_config, get_config_for_profile_home # imported lazily to avoid config-cycle churn
from api.profiles import get_hermes_home_for_profile
cfg = get_config()
try:
cfg = get_config_for_profile_home(get_hermes_home_for_profile(getattr(s, "profile", None)))
except Exception:
cfg = get_config()
base_url = _gateway_base_url(cfg)
reasoning_effort = _gateway_reasoning_effort_for_request(
cfg,
model=model,
model_provider=model_provider,
session=s,
base_url=base_url,
)
try:
from api.streaming import (
@@ -617,7 +647,6 @@ def _run_gateway_chat_streaming(
except Exception:
logger.debug("Failed to load WebUI gateway prefill context", exc_info=True)
prefill_messages = []
base_url = _gateway_base_url(cfg)
api_key = _gateway_api_key()
try:
from api.config import _main_model_request_overrides
+4 -1
View File
@@ -1067,6 +1067,7 @@ class Session:
worktree_repo_root=None,
worktree_created_at=None,
enabled_toolsets=None,
reasoning_effort=None,
composer_draft=None,
anchor_activity_scenes=None,
**kwargs):
@@ -1138,6 +1139,7 @@ class Session:
self.source_label = kwargs.get('source_label')
self.read_only = bool(kwargs.get('read_only', False))
self.enabled_toolsets = enabled_toolsets # List[str] or None — per-session toolset override
self.reasoning_effort = reasoning_effort
self.composer_draft = composer_draft if isinstance(composer_draft, dict) else {}
self.anchor_activity_scenes = anchor_activity_scenes if isinstance(anchor_activity_scenes, dict) else {}
raw_message_count = kwargs.get('message_count')
@@ -1199,7 +1201,7 @@ class Session:
'parent_session_id',
'worktree_path', 'worktree_branch', 'worktree_repo_root', 'worktree_created_at',
'is_cli_session', 'source_tag', 'raw_source', 'session_source', 'source_label', 'read_only',
'enabled_toolsets', 'composer_draft', 'anchor_activity_scenes',
'enabled_toolsets', 'reasoning_effort', 'composer_draft', 'anchor_activity_scenes',
]
meta = {k: getattr(self, k, None) for k in METADATA_FIELDS}
meta['message_count'] = len(self.messages or [])
@@ -1471,6 +1473,7 @@ class Session:
'source_label': self.source_label,
'read_only': self.read_only,
'enabled_toolsets': self.enabled_toolsets,
'reasoning_effort': self.reasoning_effort,
'composer_draft': self.composer_draft if isinstance(self.composer_draft, dict) else {},
'is_streaming': _is_streaming_session(
self.active_stream_id, active_stream_ids
+40
View File
@@ -2807,6 +2807,7 @@ from api.config import (
get_reasoning_status,
set_reasoning_display,
set_reasoning_effort,
set_session_reasoning_effort,
create_stream_channel,
get_webui_session_save_mode,
STREAM_GOAL_RELATED,
@@ -11616,12 +11617,19 @@ def handle_get(handler, parsed) -> bool:
model_id = (query.get("model", [""])[0] or "").strip() or None
provider_id = (query.get("provider", [""])[0] or "").strip() or None
base_url = (query.get("base_url", [""])[0] or "").strip() or None
session_id = (query.get("session_id", [""])[0] or "").strip() or None
session = None
if session_id:
session = get_session(session_id, metadata_only=True)
if session is None or not _session_visible_to_active_profile(getattr(session, "profile", None), handler):
return bad(handler, "Session not found", 404)
return j(
handler,
get_reasoning_status(
model_id=model_id,
provider_id=provider_id,
base_url=base_url,
session=session,
),
)
@@ -13265,6 +13273,7 @@ def handle_post(handler, parsed) -> bool:
# re-derive on the next turn.
personality=session.personality,
enabled_toolsets=getattr(session, "enabled_toolsets", None),
reasoning_effort=getattr(session, "reasoning_effort", None),
context_length=getattr(session, "context_length", None),
threshold_tokens=getattr(session, "threshold_tokens", None),
truncation_watermark=getattr(session, "truncation_watermark", None),
@@ -13416,6 +13425,35 @@ def handle_post(handler, parsed) -> bool:
except RuntimeError as e:
return bad(handler, str(e), 500)
if parsed.path == "/api/session/reasoning":
try:
require(body, "session_id")
except ValueError as e:
return bad(handler, str(e))
sid = str(body.get("session_id") or "").strip()
if not sid:
return bad(handler, "session_id is required")
session = get_session(sid)
if session is None:
return bad(handler, "Session not found", 404)
if not _session_visible_to_active_profile(getattr(session, "profile", None), handler):
return bad(handler, "Session not found", 404)
try:
return j(
handler,
set_session_reasoning_effort(
session,
body.get("effort"),
model_id=str(body.get("model") or "").strip() or None,
provider_id=str(body.get("provider") or "").strip() or None,
base_url=str(body.get("base_url") or "").strip() or None,
),
)
except ValueError as e:
return bad(handler, str(e))
except RuntimeError as e:
return bad(handler, str(e), 500)
if parsed.path == "/api/admin/reload":
# Hot-reload api.models module to pick up code changes without restart.
import importlib
@@ -14016,6 +14054,7 @@ def handle_post(handler, parsed) -> bool:
project_id=getattr(source, "project_id", None),
personality=getattr(source, "personality", None),
enabled_toolsets=getattr(source, "enabled_toolsets", None),
reasoning_effort=getattr(source, "reasoning_effort", None),
context_length=getattr(source, "context_length", None),
threshold_tokens=getattr(source, "threshold_tokens", None),
# context_messages — truncated to fork prefix (not full parent copy)
@@ -19952,6 +19991,7 @@ def _handle_session_compression_recovery_start(handler, body):
session_source="fork",
personality=getattr(source, "personality", None),
enabled_toolsets=copy.deepcopy(getattr(source, "enabled_toolsets", None)),
reasoning_effort=getattr(source, "reasoning_effort", None),
context_length=getattr(source, "context_length", None),
threshold_tokens=getattr(source, "threshold_tokens", None),
gateway_routing=copy.deepcopy(getattr(source, "gateway_routing", None)),
+32 -16
View File
@@ -38,6 +38,7 @@ from api.config import (
resolve_custom_provider_connection,
model_with_provider_context,
load_settings,
get_reasoning_status,
parse_reasoning_effort,
coerce_reasoning_effort_for_model,
_main_model_request_overrides,
@@ -6327,6 +6328,27 @@ def _refresh_cached_agent_primary_runtime_snapshot(agent) -> None:
rt['is_anthropic_oauth'] = getattr(agent, '_is_anthropic_oauth')
def _resolve_turn_reasoning_config(session, *, model_id=None, provider_id=None, base_url=None, config_data=None):
"""Return the reasoning_config for the current turn."""
try:
status = get_reasoning_status(
session=session,
model_id=model_id,
provider_id=provider_id,
base_url=base_url,
config_data=config_data,
)
effort = coerce_reasoning_effort_for_model(
status.get('reasoning_effort'),
model_id,
provider_id=provider_id,
base_url=base_url,
)
return parse_reasoning_effort(effort)
except Exception:
return None
def _run_agent_streaming(
session_id,
msg_text,
@@ -7728,22 +7750,16 @@ def _run_agent_streaming(
except Exception:
_max_tokens_cfg = None
# CLI-parity reasoning effort: read agent.reasoning_effort from the
# active profile's config.yaml (the same key the CLI writes via
# `/reasoning <level>`) and hand the parsed dict to AIAgent. When
# the key is absent or invalid, pass None → agent uses its default.
try:
_effort_cfg = _cfg.get('agent', {}) if isinstance(_cfg, dict) else {}
_effort_raw = _effort_cfg.get('reasoning_effort') if isinstance(_effort_cfg, dict) else None
_effort = coerce_reasoning_effort_for_model(
_effort_raw,
resolved_model,
provider_id=resolved_provider,
base_url=resolved_base_url,
)
_reasoning_config = parse_reasoning_effort(_effort)
except Exception:
_reasoning_config = None
# Resolve the effective reasoning effort through the shared owner
# so session overrides win without duplicating the session/profile
# decision here.
_reasoning_config = _resolve_turn_reasoning_config(
s,
model_id=resolved_model,
provider_id=resolved_provider,
base_url=resolved_base_url,
config_data=_cfg,
)
_agent_kwargs = dict(
model=resolved_model,
+14 -4
View File
@@ -1735,8 +1735,7 @@ function cmdReasoning(args){
}
if(!arg){
// Status — read from the same config.yaml keys the CLI uses.
const q=(typeof _reasoningEffortQuery==='function')?_reasoningEffortQuery():'';
api('/api/reasoning'+q).then(function(st){showToast(_fmtStatus(st));})
api('/api/reasoning').then(function(st){showToast(_fmtStatus(st));})
.catch(function(){showToast(BRAIN+' /reasoning — status unavailable');});
return true;
}
@@ -1763,8 +1762,19 @@ function cmdReasoning(args){
api('/api/reasoning',{method:'POST',body:JSON.stringify({effort:arg})})
.then(function(st){
const eff=(st && st.reasoning_effort)||arg;
showToast(BRAIN+' Reasoning effort: '+eff+' (saved; applies to next turn)');
if(typeof _applyReasoningChip==='function') _applyReasoningChip(eff, st||{});
const q=(typeof _reasoningEffortQuery==='function')?_reasoningEffortQuery():'';
showToast(BRAIN+' Reasoning effort: '+eff+(q?' (saved as profile default)':' (saved; applies to next turn)'));
if(typeof _applyReasoningChip!=='function') return;
if(!q){
_applyReasoningChip(eff, st||{});
return;
}
api('/api/reasoning'+q).then(function(fresh){
const next=(fresh && fresh.reasoning_effort)||eff;
_applyReasoningChip(next, fresh||st||{});
}).catch(function(){
_applyReasoningChip(eff, st||{});
});
})
.catch(function(e){
showToast(BRAIN+' Failed to set effort: '+(e && e.message ? e.message : arg));
+1 -1
View File
@@ -2587,7 +2587,7 @@
.composer-reasoning-icon,.composer-reasoning-chevron{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;line-height:1;}
.composer-reasoning-chevron{display:none;}
.composer-reasoning-label{display:inline-flex;align-items:center;justify-content:center;min-width:1.6ch;font-size:11px;font-weight:700;letter-spacing:0;text-transform:none;}
.composer-reasoning-dropdown{display:none;position:absolute;bottom:calc(100% + 4px);left:0;min-width:140px;background:var(--surface);border:1px solid var(--border2);border-radius:10px;box-shadow:0 -4px 24px rgba(0,0,0,.4);z-index:200;padding:4px;overflow:hidden;}
.composer-reasoning-dropdown{display:none;position:absolute;bottom:calc(100% + 4px);left:0;min-width:140px;background:var(--surface);border:1px solid var(--border2);border-radius:10px;box-shadow:0 -4px 24px rgba(0,0,0,.4);z-index:200;padding:4px;max-height:min(60vh,420px);overflow-x:hidden;overflow-y:auto;}
.composer-reasoning-dropdown.open{display:block;}
.reasoning-option{padding:8px 14px;border-radius:6px;cursor:pointer;font-size:13px;color:var(--text);white-space:nowrap;transition:background-color .12s;}
.reasoning-option:hover{background:rgba(255,255,255,.07);}
+205 -13
View File
@@ -4325,12 +4325,19 @@ if(document.readyState==='loading'){
// ── Reasoning effort chip ────────────────────────────────────────────────────
let _currentReasoningEffort=null;
let _currentReasoningScope='profile';
let _currentReasoningStatus=null;
let _currentReasoningEffortsSupported=null;
let _reasoningDropdownBuilt=false;
function _normalizeReasoningEffort(eff){
return String(eff||'').trim().toLowerCase();
}
function _normalizeReasoningScope(scope){
return scope==='session' ? 'session' : 'profile';
}
function _formatReasoningEffortLabel(effort){
if(effort==='none') return 'None';
if(!effort) return 'Default';
@@ -4342,6 +4349,18 @@ function _formatReasoningEffortLabel(effort){
return effort.charAt(0).toUpperCase()+effort.slice(1);
}
function _formatReasoningScopeLabel(scope){
return _normalizeReasoningScope(scope)==='session' ? 'Session' : 'Profile';
}
function _reasoningOptionLabel(scope, effortLabel){
return effortLabel;
}
function _activeReasoningSessionId(){
return (S&&S.session&&S.session.session_id) ? String(S.session.session_id) : '';
}
function _reasoningEffortContext(){
const sel=$('modelSelect');
const model=(S&&S.session&&S.session.model)||(sel&&sel.value)||'';
@@ -4352,6 +4371,10 @@ function _reasoningEffortContext(){
const ctx={};
if(model) ctx.model=model;
if(provider) ctx.provider=provider;
const sessionId=typeof _activeReasoningSessionId==='function'
? _activeReasoningSessionId()
: ((S&&S.session&&S.session.session_id) ? String(S.session.session_id) : '');
if(sessionId) ctx.session_id=sessionId;
return ctx;
}
@@ -4361,11 +4384,70 @@ function _reasoningEffortQuery(){
return qs?('?'+qs):'';
}
function _reasoningWriteContext(scope){
const ctx=_reasoningEffortContext();
if(_normalizeReasoningScope(scope)==='profile'){
delete ctx.session_id;
return ctx;
}
if(!ctx.session_id) return null;
return ctx;
}
function _reasoningEndpointForScope(scope){
return _normalizeReasoningScope(scope)==='session' ? '/api/session/reasoning' : '/api/reasoning';
}
function _ensureReasoningDropdownOptions(){
const dd=$('composerReasoningDropdown');
if(!dd||_reasoningDropdownBuilt) return;
const source=Array.from(dd.querySelectorAll('.reasoning-option'));
if(!source.length) return;
const templates=source.map(function(opt){
return {
effort:String(opt.dataset.effort||'').trim().toLowerCase(),
label:String(opt.textContent||'').trim() || _formatReasoningEffortLabel(opt.dataset.effort),
};
});
dd.innerHTML='';
['session','profile'].forEach(function(scope){
const header=document.createElement('div');
header.className='reasoning-option-group';
header.textContent=_normalizeReasoningScope(scope)==='session' ? 'This session' : 'Profile default';
header.style.cssText='padding:6px 10px 4px;font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;';
dd.appendChild(header);
if(_normalizeReasoningScope(scope)==='session'){
// The session override is otherwise a one-way door: the backend clears it
// on an empty POST, but nothing else in the UI ever sends that clear.
const clearOpt=document.createElement('div');
clearOpt.className='reasoning-option';
clearOpt.dataset.scope='session';
clearOpt.dataset.clear='1';
clearOpt.textContent='Use profile default';
dd.appendChild(clearOpt);
}
templates.forEach(function(template){
const opt=document.createElement('div');
opt.className='reasoning-option';
opt.dataset.effort=template.effort;
opt.dataset.scope=scope;
opt.textContent=_reasoningOptionLabel(scope, template.label);
dd.appendChild(opt);
});
});
_reasoningDropdownBuilt=true;
}
function _applyReasoningOptions(supportedEfforts){
_ensureReasoningDropdownOptions();
const dd=$('composerReasoningDropdown');
if(!dd) return;
const supported=new Set(Array.isArray(supportedEfforts)?supportedEfforts:[]);
dd.querySelectorAll('.reasoning-option').forEach(function(opt){
if(opt.dataset.clear){
opt.style.display='';
return;
}
const effort=opt.dataset.effort;
if(effort==='none'){
opt.style.display='';
@@ -4382,7 +4464,17 @@ function _applyReasoningOptions(supportedEfforts){
function _applyReasoningChip(eff){
const meta=arguments[1]||null;
const effort=_normalizeReasoningEffort(eff);
const hasScopeFormatter=typeof _formatReasoningScopeLabel==='function';
const normalizeScope=(typeof _normalizeReasoningScope==='function')
? _normalizeReasoningScope
: function(scope){return scope==='session'?'session':'profile';};
const formatScopeLabel=hasScopeFormatter
? _formatReasoningScopeLabel
: function(scope){return scope==='session'?'This session':'Profile default';};
const scope=normalizeScope(meta&&meta.reasoning_scope);
_currentReasoningEffort=effort;
_currentReasoningScope=scope;
_currentReasoningStatus=meta&&typeof meta==='object' ? meta : null;
if(meta&&Array.isArray(meta.supported_efforts)){
_currentReasoningEffortsSupported=meta.supported_efforts;
}
@@ -4407,17 +4499,23 @@ function _applyReasoningChip(eff){
if(mobileAction) mobileAction.style.display='';
if(typeof _applyReasoningOptions==='function') _applyReasoningOptions(supportedEfforts);
const text=_formatReasoningEffortLabel(effort);
const scopeText=formatScopeLabel(scope);
const displayText=hasScopeFormatter && scope==='session' ? (text+' · '+scopeText) : text;
label.textContent=text;
if(hasScopeFormatter) label.textContent=displayText;
if(mobileLabel) mobileLabel.textContent=text;
if(mobileLabel&&hasScopeFormatter) mobileLabel.textContent=displayText;
if(chip){
const inactive=!effort||effort==='none';
chip.classList.toggle('inactive',inactive);
const labelText='Reasoning effort: '+text;
const labelText=hasScopeFormatter
? ('Reasoning effort: '+text+' ('+scopeText.toLowerCase()+' scope)')
: ('Reasoning effort: '+text);
chip.title=labelText;
chip.setAttribute('aria-label',labelText);
}
if(mobileAction) mobileAction.classList.toggle('inactive',!effort||effort==='none');
_highlightReasoningOption(effort);
_highlightReasoningOption(effort, scope, _currentReasoningStatus);
}
// Tracks the model/provider identity of the last reasoning fetch so routine
@@ -4472,17 +4570,53 @@ function syncReasoningChip(){
// response (10 syncs before the first GET resolves must produce ONE request,
// not ten). Apply the cached chip only once we actually have an effort value.
if(_lastReasoningFetchKey===key){
if(_currentReasoningEffort!==null) _applyReasoningChip(_currentReasoningEffort);
const status=(typeof _currentReasoningStatus==='undefined') ? null : _currentReasoningStatus;
if(status) _applyReasoningChip(_currentReasoningEffort, status);
else if(_currentReasoningEffort!==null) _applyReasoningChip(_currentReasoningEffort, {
reasoning_scope:(typeof _currentReasoningScope==='undefined') ? 'profile' : _currentReasoningScope,
supported_efforts:_currentReasoningEffortsSupported||[],
});
return;
}
fetchReasoningChip();
}
function _highlightReasoningOption(effort){
function _highlightReasoningOption(effort, scope, meta){
if(typeof _ensureReasoningDropdownOptions==='function') _ensureReasoningDropdownOptions();
const dd=$('composerReasoningDropdown');
if(!dd) return;
const normalizeScope=(typeof _normalizeReasoningScope==='function')
? _normalizeReasoningScope
: function(s){return s==='session'?'session':'profile';};
const normalizeEffort=(typeof _normalizeReasoningEffort==='function')
? _normalizeReasoningEffort
: function(e){return String(e||'').trim().toLowerCase();};
const status=(meta&&typeof meta==='object')
? meta
: ((typeof _currentReasoningStatus!=='undefined' && _currentReasoningStatus) ? _currentReasoningStatus : null);
const normScope=normalizeScope(scope);
// Highlight each group by its own real value so both stay truthful when a
// session override masks the profile default (and vice versa).
const hasOverride=status
? !!status.has_session_reasoning_override
: normScope==='session';
const sessionEffort=(status&&status.session_reasoning_effort!=null)
? normalizeEffort(status.session_reasoning_effort)
: (normScope==='session' ? normalizeEffort(effort) : null);
const profileEffort=(status&&status.profile_reasoning_effort!=null)
? normalizeEffort(status.profile_reasoning_effort)
: (normScope==='profile' ? normalizeEffort(effort) : null);
dd.querySelectorAll('.reasoning-option').forEach(function(opt){
opt.classList.toggle('selected',opt.dataset.effort===effort);
const optScope=normalizeScope(opt.dataset.scope);
if(opt.dataset.clear){
// The clear row is the active session state whenever no override is set.
opt.classList.toggle('selected', optScope==='session' && !hasOverride);
return;
}
const on=optScope==='session'
? (hasOverride && opt.dataset.effort===sessionEffort)
: (profileEffort!=null && opt.dataset.effort===profileEffort);
opt.classList.toggle('selected', on);
});
}
@@ -4496,7 +4630,8 @@ function toggleReasoningDropdown(){
if(typeof closeWsDropdown==='function') closeWsDropdown();
closeModelDropdown();
if(typeof closeToolsetsDropdown==='function') closeToolsetsDropdown();
_highlightReasoningOption(_currentReasoningEffort);
_ensureReasoningDropdownOptions();
_highlightReasoningOption(_currentReasoningEffort, _currentReasoningScope, _currentReasoningStatus);
dd.classList.add('open');
_positionReasoningDropdown();
chip.classList.add('active');
@@ -4529,6 +4664,63 @@ function closeReasoningDropdown(){
if(mobileAction) mobileAction.classList.remove('active');
}
function _sendReasoningEffort(scope, effort){
const normalizedScope=_normalizeReasoningScope(scope);
const payload=Object.assign({effort:effort},_reasoningEffortContext());
if(normalizedScope==='profile'){
delete payload.session_id;
}else if(!payload.session_id){
if(typeof showToast==='function') showToast('Select a session before using session-only reasoning.', 3000, 'warning');
return Promise.resolve(null);
}
return api(_reasoningEndpointForScope(normalizedScope),{method:'POST',body:JSON.stringify(payload)})
.then(function(st){
if(normalizedScope==='profile' && _activeReasoningSessionId()){
_lastReasoningFetchKey=null;
return api('/api/reasoning'+_reasoningEffortQuery()).then(function(fresh){
const applied=fresh||st||{reasoning_scope:normalizedScope};
const appliedScope=_normalizeReasoningScope((applied&&applied.reasoning_scope)||normalizedScope);
_applyReasoningChip((applied&&applied.reasoning_effort)||effort, applied);
if(appliedScope==='session'){
// The profile write landed but an active session override still wins,
// so name what actually happened instead of announcing the override.
const overrideLabel=_formatReasoningEffortLabel((applied&&applied.reasoning_effort)||'');
showToast('🧠 Profile default saved: '+_formatReasoningEffortLabel(effort)+'. This conversation keeps its '+overrideLabel+' session override.');
}else{
showToast('🧠 Reasoning effort set to '+((applied&&applied.reasoning_effort)||effort)+' ('+_formatReasoningScopeLabel(appliedScope).toLowerCase()+')');
}
return applied;
}).catch(function(){
const fallbackScope=_normalizeReasoningScope((st&&st.reasoning_scope)||normalizedScope);
_applyReasoningChip((st&&st.reasoning_effort)||effort, st||{reasoning_scope:fallbackScope});
showToast('🧠 Reasoning effort set to '+((st&&st.reasoning_effort)||effort)+' ('+_formatReasoningScopeLabel(fallbackScope).toLowerCase()+')');
return st;
});
}
const nextScope=_normalizeReasoningScope((st&&st.reasoning_scope)||normalizedScope);
_applyReasoningChip((st&&st.reasoning_effort)||effort, st||{reasoning_scope:nextScope});
showToast('🧠 Reasoning effort set to '+((st&&st.reasoning_effort)||effort)+' ('+_formatReasoningScopeLabel(nextScope).toLowerCase()+')');
return st;
});
}
function _clearSessionReasoningEffort(){
const sessionId=_activeReasoningSessionId();
if(!sessionId){
if(typeof showToast==='function') showToast('Select a session before clearing its reasoning override.', 3000, 'warning');
return Promise.resolve(null);
}
const payload=Object.assign({effort:''},_reasoningEffortContext());
return api('/api/session/reasoning',{method:'POST',body:JSON.stringify(payload)})
.then(function(st){
const scope=_normalizeReasoningScope((st&&st.reasoning_scope)||'profile');
_applyReasoningChip((st&&st.reasoning_effort)||'', st||{reasoning_scope:scope});
const eff=st&&st.reasoning_effort;
showToast('🧠 This conversation now follows the profile default'+(eff?' ('+_formatReasoningEffortLabel(eff)+')':''));
return st;
});
}
document.addEventListener('click',function(e){
if(
!e.target.closest('#composerReasoningChip') &&
@@ -4537,15 +4729,15 @@ document.addEventListener('click',function(e){
) closeReasoningDropdown();
if(e.target.closest('.reasoning-option')){
const opt=e.target.closest('.reasoning-option');
if(opt&&opt.dataset.clear){
_clearSessionReasoningEffort().catch(function(){showToast('🧠 Failed to clear session override');});
closeReasoningDropdown();
return;
}
const effort=opt&&opt.dataset.effort;
const scope=opt&&opt.dataset.scope;
if(effort){
const payload=Object.assign({effort:effort},_reasoningEffortContext());
api('/api/reasoning',{method:'POST',body:JSON.stringify(payload)})
.then(function(st){
_applyReasoningChip((st&&st.reasoning_effort)||effort, st||{});
showToast('🧠 Reasoning effort set to '+((st&&st.reasoning_effort)||effort));
})
.catch(function(){showToast('🧠 Failed to set effort');});
_sendReasoningEffort(scope, effort).catch(function(){showToast('🧠 Failed to set effort');});
closeReasoningDropdown();
}
}
+904
View File
@@ -0,0 +1,904 @@
from __future__ import annotations
import collections
import json
import os
import shutil
import subprocess
import tempfile
import threading
from pathlib import Path
from urllib.parse import urlparse
import pytest
import yaml
import api.config as cfg
import api.models as models
import api.routes as routes
from api.config import get_reasoning_status
from api.gateway_chat import _gateway_reasoning_effort_for_request
from api.models import Session
from api.routes import handle_get, handle_post
from api.streaming import _resolve_turn_reasoning_config
ROOT = Path(__file__).resolve().parents[1]
UI_JS_PATH = ROOT / "static" / "ui.js"
COMMANDS_JS_PATH = ROOT / "static" / "commands.js"
NODE = shutil.which("node")
class _DummyHandler:
def __init__(self, body: dict | None = None, *, command: str = "POST"):
raw = json.dumps(body or {}).encode("utf-8")
self.command = command
self.headers = {"Content-Length": str(len(raw))}
self.rfile = tempfile.SpooledTemporaryFile()
self.rfile.write(raw)
self.rfile.seek(0)
self.status = None
self.response = {}
self.wfile = tempfile.SpooledTemporaryFile()
self.client_address = ("127.0.0.1", 12345)
def send_response(self, code: int):
self.status = code
def send_header(self, key: str, value: str):
self.response.setdefault("headers", {})[key] = value
def end_headers(self):
pass
def payload(self) -> dict:
self.wfile.seek(0)
return json.loads(self.wfile.read().decode("utf-8"))
@pytest.fixture
def isolated_reasoning_env(tmp_path, monkeypatch):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
cfgfile = tmp_path / "config.yaml"
cfgfile.write_text(
yaml.safe_dump(
{
"model": {"default": "gpt-5", "provider": "openai"},
"agent": {"reasoning_effort": "medium"},
},
sort_keys=False,
),
encoding="utf-8",
)
lock = threading.RLock()
sessions = collections.OrderedDict()
session_index = sessions_dir / "index.json"
for mod in (cfg, models, routes):
monkeypatch.setattr(mod, "SESSION_DIR", sessions_dir, raising=False)
monkeypatch.setattr(mod, "SESSION_INDEX_FILE", session_index, raising=False)
monkeypatch.setattr(mod, "LOCK", lock, raising=False)
monkeypatch.setattr(mod, "SESSIONS", sessions, raising=False)
monkeypatch.setattr(mod, "SESSIONS_MAX", 100, raising=False)
monkeypatch.setattr(cfg, "_get_config_path", lambda: cfgfile)
monkeypatch.setattr(cfg, "reload_config", lambda: None)
monkeypatch.setattr(routes, "_session_visible_to_active_profile", lambda *args, **kwargs: True, raising=False)
return {
"config_path": cfgfile,
"sessions_dir": sessions_dir,
"session_index": session_index,
}
def _run_reasoning_ui_script(script: str) -> dict:
if NODE is None:
pytest.skip("node is required for the reasoning chip UI harness")
harness = f"""
const fs = require('fs');
const src = fs.readFileSync(process.env.UI_JS_PATH, 'utf8');
function extractBlock(startMarker, endMarker) {{
const start = src.indexOf(startMarker);
if (start < 0) throw new Error('start marker not found: ' + startMarker);
const end = src.indexOf(endMarker, start);
if (end < 0) throw new Error('end marker not found: ' + endMarker);
return src.slice(start, end);
}}
class FakeClassList {{
constructor(owner) {{
this.owner = owner;
this._set = new Set();
}}
add(...names) {{ names.forEach(name => this._set.add(name)); }}
remove(...names) {{ names.forEach(name => this._set.delete(name)); }}
contains(name) {{ return this._set.has(name); }}
toggle(name, force) {{
if (force === true) {{ this._set.add(name); return true; }}
if (force === false) {{ this._set.delete(name); return false; }}
if (this._set.has(name)) {{ this._set.delete(name); return false; }}
this._set.add(name);
return true;
}}
}}
class FakeElement {{
constructor(tagName='div') {{
this.tagName = String(tagName).toUpperCase();
this.children = [];
this.parentNode = null;
this.dataset = Object.create(null);
this.style = Object.create(null);
this.attributes = Object.create(null);
this.classList = new FakeClassList(this);
this.id = '';
this.title = '';
this.ariaLabel = '';
this._textContent = '';
this._innerHTML = '';
this.offsetWidth = 200;
}}
appendChild(child) {{
child.parentNode = this;
this.children.push(child);
return child;
}}
querySelectorAll(selector) {{
if (selector !== '.reasoning-option') return [];
const out = [];
const walk = node => {{
for (const child of node.children) {{
if (child.classList && child.classList.contains('reasoning-option')) out.push(child);
walk(child);
}}
}};
walk(this);
return out;
}}
getBoundingClientRect() {{
return {{ left: 0 }};
}}
setAttribute(name, value) {{
this.attributes[String(name)] = String(value);
if (name === 'aria-label') this.ariaLabel = String(value);
}}
get className() {{
return Array.from(this.classList._set).join(' ');
}}
set className(value) {{
this.classList._set = new Set(String(value ?? '').split(/\\s+/).filter(Boolean));
}}
get textContent() {{
return this._textContent;
}}
set textContent(value) {{
this._textContent = String(value ?? '');
this._innerHTML = this._textContent;
this.children = [];
}}
get innerHTML() {{
return this._innerHTML;
}}
set innerHTML(value) {{
this._innerHTML = String(value ?? '');
this._textContent = this._innerHTML;
this.children = [];
}}
}}
const nodes = Object.create(null);
function bind(id, node) {{
node.id = id;
nodes[id] = node;
return node;
}}
const dropdown = bind('composerReasoningDropdown', new FakeElement('div'));
['None', 'Minimal', 'Low', 'Medium', 'High', 'Extra High'].forEach((label, idx) => {{
const opt = new FakeElement('div');
opt.classList.add('reasoning-option');
opt.dataset.effort = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh'][idx];
opt.textContent = label;
dropdown.appendChild(opt);
}});
bind('composerReasoningWrap', new FakeElement('div'));
bind('composerReasoningLabel', new FakeElement('div'));
bind('composerReasoningChip', new FakeElement('div'));
bind('composerMobileReasoningLabel', new FakeElement('div'));
bind('composerMobileReasoningAction', new FakeElement('div'));
bind('modelSelect', {{ value: 'gpt-5' }});
global.window = {{}};
global.document = {{
readyState: 'complete',
addEventListener: () => {{}},
querySelector: () => null,
createElement: tag => new FakeElement(tag),
}};
global.$ = id => nodes[id] || null;
const toasts = [];
const fetches = [];
const responses = [];
global.showToast = (msg, ms, type) => toasts.push({{ msg, ms, type }});
global.api = (url, opts) => {{
fetches.push({{ url, opts: opts ? {{ method: opts.method, body: opts.body }} : null }});
const next = responses.length ? responses.shift() : null;
return Promise.resolve(typeof next === 'function' ? next(url, opts) : next);
}};
global.S = {{ session: {{ session_id: 'session-a', model: 'gpt-5', model_provider: 'openai' }} }};
eval(extractBlock('// ── Reasoning effort chip ────────────────────────────────────────────────────', '// ── Session toolsets chip (#493) ───────────────────────────────────────────'));
async function tick() {{
await new Promise(resolve => setImmediate(resolve));
}}
async function main() {{
{script}
}}
main().then(result => {{
const snapshot = {{
fetches,
toasts,
label: nodes.composerReasoningLabel.textContent,
mobileLabel: nodes.composerMobileReasoningLabel.textContent,
selected: dropdown.querySelectorAll('.reasoning-option').filter(opt => opt.classList.contains('selected')).map(opt => opt.textContent),
optionCount: dropdown.querySelectorAll('.reasoning-option').length,
}};
process.stdout.write(JSON.stringify(Object.assign(snapshot, result && typeof result === 'object' ? result : {{}})));
}}).catch(err => {{
process.stderr.write(String(err && err.stack || err));
process.exit(1);
}});
"""
env = os.environ.copy()
env["UI_JS_PATH"] = str(UI_JS_PATH)
result = subprocess.run(
["node", "-e", harness],
cwd=ROOT,
env=env,
text=True,
capture_output=True,
check=False,
)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def _run_reasoning_command_script(script: str) -> dict:
if NODE is None:
pytest.skip("node is required for the reasoning command harness")
harness = f"""
const fs = require('fs');
const uiSrc = fs.readFileSync(process.env.UI_JS_PATH, 'utf8');
const commandsSrc = fs.readFileSync(process.env.COMMANDS_JS_PATH, 'utf8');
function extractBlock(src, startMarker, endMarker) {{
const start = src.indexOf(startMarker);
if (start < 0) throw new Error('start marker not found: ' + startMarker);
const end = src.indexOf(endMarker, start);
if (end < 0) throw new Error('end marker not found: ' + endMarker);
return src.slice(start, end);
}}
function extractFunction(src, name) {{
const rx = new RegExp('function\\\\s+' + name + '\\\\b[\\\\s\\\\S]*?(?=^function\\\\s|\\\\Z)', 'm');
const match = src.match(rx);
if (!match) throw new Error('function not found: ' + name);
return match[0];
}}
class FakeClassList {{
constructor(owner) {{
this.owner = owner;
this._set = new Set();
}}
add(...names) {{ names.forEach(name => this._set.add(name)); }}
remove(...names) {{ names.forEach(name => this._set.delete(name)); }}
contains(name) {{ return this._set.has(name); }}
toggle(name, force) {{
if (force === true) {{ this._set.add(name); return true; }}
if (force === false) {{ this._set.delete(name); return false; }}
if (this._set.has(name)) {{ this._set.delete(name); return false; }}
this._set.add(name);
return true;
}}
}}
class FakeElement {{
constructor(tagName='div') {{
this.tagName = String(tagName).toUpperCase();
this.children = [];
this.parentNode = null;
this.dataset = Object.create(null);
this.style = Object.create(null);
this.attributes = Object.create(null);
this.classList = new FakeClassList(this);
this.id = '';
this.title = '';
this.ariaLabel = '';
this._textContent = '';
this._innerHTML = '';
this.offsetWidth = 200;
}}
appendChild(child) {{
child.parentNode = this;
this.children.push(child);
return child;
}}
querySelectorAll(selector) {{
if (selector !== '.reasoning-option') return [];
const out = [];
const walk = node => {{
for (const child of node.children) {{
if (child.classList && child.classList.contains('reasoning-option')) out.push(child);
walk(child);
}}
}};
walk(this);
return out;
}}
getBoundingClientRect() {{
return {{ left: 0 }};
}}
setAttribute(name, value) {{
this.attributes[String(name)] = String(value);
if (name === 'aria-label') this.ariaLabel = String(value);
}}
get className() {{
return Array.from(this.classList._set).join(' ');
}}
set className(value) {{
this.classList._set = new Set(String(value ?? '').split(/\\s+/).filter(Boolean));
}}
get textContent() {{
return this._textContent;
}}
set textContent(value) {{
this._textContent = String(value ?? '');
this._innerHTML = this._textContent;
this.children = [];
}}
get innerHTML() {{
return this._innerHTML;
}}
set innerHTML(value) {{
this._innerHTML = String(value ?? '');
this._textContent = this._innerHTML;
this.children = [];
}}
}}
const nodes = Object.create(null);
function bind(id, node) {{
node.id = id;
nodes[id] = node;
return node;
}}
const dropdown = bind('composerReasoningDropdown', new FakeElement('div'));
['None', 'Minimal', 'Low', 'Medium', 'High', 'Extra High'].forEach((label, idx) => {{
const opt = new FakeElement('div');
opt.classList.add('reasoning-option');
opt.dataset.effort = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh'][idx];
opt.textContent = label;
dropdown.appendChild(opt);
}});
bind('composerReasoningWrap', new FakeElement('div'));
bind('composerReasoningLabel', new FakeElement('div'));
bind('composerReasoningChip', new FakeElement('div'));
bind('composerMobileReasoningLabel', new FakeElement('div'));
bind('composerMobileReasoningAction', new FakeElement('div'));
bind('modelSelect', {{ value: 'gpt-5' }});
global.window = {{}};
global.document = {{
readyState: 'complete',
addEventListener: () => {{}},
querySelector: () => null,
createElement: tag => new FakeElement(tag),
}};
global.$ = id => nodes[id] || null;
global.t = key => key;
global.removeThinking = () => {{}};
global.renderMessages = () => {{}};
const toasts = [];
const fetches = [];
const responses = [];
global.showToast = (msg, ms, type) => toasts.push({{ msg, ms, type }});
global.api = (url, opts) => {{
fetches.push({{ url, opts: opts ? {{ method: opts.method, body: opts.body }} : null }});
const next = responses.length ? responses.shift() : null;
return Promise.resolve(typeof next === 'function' ? next(url, opts) : next);
}};
global.S = {{ session: {{ session_id: 'session-a', model: 'gpt-5', model_provider: 'openai' }} }};
eval(extractBlock(uiSrc, '// ── Reasoning effort chip ────────────────────────────────────────────────────', '// ── Session toolsets chip (#493) ───────────────────────────────────────────'));
eval(extractFunction(commandsSrc, 'cmdReasoning'));
async function tick() {{
await new Promise(resolve => setImmediate(resolve));
}}
async function main() {{
{script}
}}
main().then(result => {{
const snapshot = {{
fetches,
toasts,
label: nodes.composerReasoningLabel.textContent,
}};
process.stdout.write(JSON.stringify(Object.assign(snapshot, result && typeof result === 'object' ? result : {{}})));
}}).catch(err => {{
process.stderr.write(String(err && err.stack || err));
process.exit(1);
}});
"""
env = os.environ.copy()
env["UI_JS_PATH"] = str(UI_JS_PATH)
env["COMMANDS_JS_PATH"] = str(COMMANDS_JS_PATH)
result = subprocess.run(
["node", "-e", harness],
cwd=ROOT,
env=env,
text=True,
capture_output=True,
check=False,
)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_session_reasoning_sidecar_round_trip_and_compact_repr(isolated_reasoning_env):
sid = "session-sidecar-roundtrip"
session = Session(
session_id=sid,
title="Reasoning",
model="gpt-5",
model_provider="openai",
reasoning_effort="high",
)
session.save()
compact = session.compact()
assert compact["reasoning_effort"] == "high"
loaded = Session.load(sid)
assert loaded is not None
assert loaded.reasoning_effort == "high"
assert loaded.compact()["reasoning_effort"] == "high"
loaded.reasoning_effort = None
loaded.save()
cleared = Session.load(sid)
assert cleared is not None
assert cleared.reasoning_effort is None
assert cleared.compact()["reasoning_effort"] is None
cleared.reasoning_effort = "none"
cleared.save()
disabled = Session.load(sid)
assert disabled is not None
assert disabled.reasoning_effort == "none"
assert disabled.compact()["reasoning_effort"] == "none"
def test_reasoning_status_and_streaming_use_session_first_resolution(isolated_reasoning_env):
session = Session(
session_id="session-reasoning-status",
title="Session A",
model="gpt-5",
model_provider="openai",
reasoning_effort="high",
)
session.save()
status = get_reasoning_status(session=session)
assert status["reasoning_scope"] == "session"
assert status["has_session_reasoning_override"] is True
assert status["session_reasoning_effort"] == "high"
assert status["profile_reasoning_effort"] == "medium"
assert status["reasoning_effort"] == "high"
status_handler = _DummyHandler(command="GET")
handle_get(status_handler, urlparse(f"http://localhost/api/reasoning?session_id={session.session_id}"))
assert status_handler.status == 200
route_status = status_handler.payload()
assert route_status["reasoning_scope"] == "session"
assert route_status["has_session_reasoning_override"] is True
assert route_status["reasoning_effort"] == "high"
assert route_status["profile_reasoning_effort"] == "medium"
profile_handler = _DummyHandler(command="GET")
handle_get(profile_handler, urlparse("http://localhost/api/reasoning"))
assert profile_handler.status == 200
profile_status = profile_handler.payload()
assert profile_status["reasoning_scope"] == "profile"
assert profile_status["reasoning_effort"] == "medium"
reasoning_config = _resolve_turn_reasoning_config(session)
assert reasoning_config == {"enabled": True, "effort": "high"}
other = Session(
session_id="session-reasoning-inherit",
title="Session B",
model="gpt-5",
model_provider="openai",
)
other.save()
other_status = get_reasoning_status(session=other)
assert other_status["reasoning_scope"] == "profile"
assert other_status["has_session_reasoning_override"] is False
assert other_status["session_reasoning_effort"] is None
assert other_status["profile_reasoning_effort"] == "medium"
assert other_status["reasoning_effort"] == "medium"
assert _resolve_turn_reasoning_config(other) == {"enabled": True, "effort": "medium"}
pinned_cfg = {
"model": {"default": "gpt-5", "provider": "openai"},
"agent": {"reasoning_effort": "high"},
}
pinned_status = get_reasoning_status(session=other, config_data=pinned_cfg)
assert pinned_status["reasoning_scope"] == "profile"
assert pinned_status["profile_reasoning_effort"] == "high"
assert pinned_status["reasoning_effort"] == "high"
assert _resolve_turn_reasoning_config(other, config_data=pinned_cfg) == {"enabled": True, "effort": "high"}
def test_session_reasoning_route_leaves_profile_default_and_global_route_stays_global(isolated_reasoning_env):
source = Session(
session_id="session-reasoning-route",
title="Route Session",
model="gpt-5",
model_provider="openai",
)
source.save()
session_handler = _DummyHandler({"session_id": source.session_id, "effort": "high"})
handle_post(session_handler, urlparse("http://localhost/api/session/reasoning"))
assert session_handler.status == 200
session_payload = session_handler.payload()
assert session_payload["reasoning_scope"] == "session"
assert session_payload["reasoning_effort"] == "high"
assert session_payload["profile_reasoning_effort"] == "medium"
assert Session.load(source.session_id).reasoning_effort == "high"
assert yaml.safe_load(isolated_reasoning_env["config_path"].read_text(encoding="utf-8"))["agent"]["reasoning_effort"] == "medium"
clear_handler = _DummyHandler({"session_id": source.session_id, "effort": None})
handle_post(clear_handler, urlparse("http://localhost/api/session/reasoning"))
assert clear_handler.status == 200
clear_payload = clear_handler.payload()
assert clear_payload["reasoning_scope"] == "profile"
assert clear_payload["reasoning_effort"] == "medium"
assert Session.load(source.session_id).reasoning_effort is None
global_handler = _DummyHandler({"effort": "low"})
handle_post(global_handler, urlparse("http://localhost/api/reasoning"))
assert global_handler.status == 200
global_payload = global_handler.payload()
assert global_payload["reasoning_scope"] == "profile"
assert global_payload["reasoning_effort"] == "low"
assert yaml.safe_load(isolated_reasoning_env["config_path"].read_text(encoding="utf-8"))["agent"]["reasoning_effort"] == "low"
assert Session.load(source.session_id).reasoning_effort is None
def test_duplicate_session_carries_reasoning_effort(isolated_reasoning_env):
source = Session(
session_id="session-reasoning-source",
title="Source Session",
model="gpt-5",
model_provider="openai",
reasoning_effort="high",
messages=[{"role": "user", "content": "hello"}],
)
source.save()
handler = _DummyHandler({"session_id": source.session_id})
handle_post(handler, urlparse("http://localhost/api/session/duplicate"))
assert handler.status == 200
payload = handler.payload()["session"]
assert payload["reasoning_effort"] == "high"
duplicated = Session.load(payload["session_id"])
assert duplicated is not None
assert duplicated.reasoning_effort == "high"
assert Session.load(source.session_id).reasoning_effort == "high"
def test_branch_session_carries_reasoning_effort(isolated_reasoning_env):
source = Session(
session_id="session-reasoning-branch-source",
title="Source Session",
model="gpt-5",
model_provider="openai",
reasoning_effort="high",
messages=[{"role": "user", "content": "hello"}],
)
source.save()
handler = _DummyHandler({"session_id": source.session_id})
handle_post(handler, urlparse("http://localhost/api/session/branch"))
assert handler.status == 200
payload = handler.payload()
branched = Session.load(payload["session_id"])
assert branched is not None
assert branched.reasoning_effort == "high"
def test_compression_continuation_carries_reasoning_effort(isolated_reasoning_env):
source = Session(
session_id="session-reasoning-compression-source",
title="Source Session",
model="gpt-5",
model_provider="openai",
reasoning_effort="high",
)
source.compression_recovery = {
"terminal_state": "compression_exhausted",
"recommended_action": "start_focused_continuation",
"source_session_id": source.session_id,
}
source.save()
handler = _DummyHandler({"session_id": source.session_id})
routes._handle_session_compression_recovery_start(handler, {"session_id": source.session_id})
assert handler.status == 200
payload = handler.payload()["session"]
continued = Session.load(payload["session_id"])
assert continued is not None
assert continued.reasoning_effort == "high"
def test_gateway_reasoning_effort_prefers_session_override(isolated_reasoning_env):
session = Session(
session_id="session-reasoning-gateway",
title="Gateway Session",
model="gpt-5",
model_provider="openai",
reasoning_effort="high",
)
session.save()
cfg_data = yaml.safe_load(isolated_reasoning_env["config_path"].read_text(encoding="utf-8"))
assert _gateway_reasoning_effort_for_request(
cfg_data,
model="gpt-5",
model_provider="openai",
session=session,
) == "high"
session.reasoning_effort = None
session.save()
pinned_cfg = {
"model": {"default": "gpt-5", "provider": "openai"},
"agent": {"reasoning_effort": "high"},
}
assert _gateway_reasoning_effort_for_request(
pinned_cfg,
model="gpt-5",
model_provider="openai",
session=session,
) == "high"
def test_reasoning_chip_cache_is_session_specific_and_session_only_action_requires_session():
data = _run_reasoning_ui_script(
"""
responses.push({
reasoning_effort: 'high',
reasoning_scope: 'session',
supported_efforts: ['none', 'medium', 'high'],
has_session_reasoning_override: true,
});
syncReasoningChip();
await tick();
const first = {
url: fetches[0].url,
label: nodes.composerReasoningLabel.textContent,
selected: dropdown.querySelectorAll('.reasoning-option').filter(opt => opt.classList.contains('selected')).map(opt => opt.textContent),
};
S.session = { session_id: 'session-b', model: 'gpt-5', model_provider: 'openai' };
responses.push({
reasoning_effort: 'medium',
reasoning_scope: 'profile',
supported_efforts: ['none', 'medium', 'high'],
has_session_reasoning_override: false,
});
syncReasoningChip();
await tick();
const second = {
url: fetches[1].url,
label: nodes.composerReasoningLabel.textContent,
selected: dropdown.querySelectorAll('.reasoning-option').filter(opt => opt.classList.contains('selected')).map(opt => opt.textContent),
};
S.session = null;
const before = fetches.length;
await _sendReasoningEffort('session', 'high');
const after = fetches.length;
const guardToast = toasts[toasts.length - 1];
return {
first,
second,
before,
after,
guardToast,
optionCount: dropdown.querySelectorAll('.reasoning-option').length,
};
"""
)
assert "session_id=session-a" in data["first"]["url"]
assert data["first"]["label"].startswith("High")
assert data["first"]["label"].endswith("Session")
assert "session_id=session-b" in data["second"]["url"]
assert data["second"]["label"] == "Medium"
assert data["before"] == data["after"]
assert data["guardToast"]["type"] == "warning"
assert "Select a session" in data["guardToast"]["msg"]
def test_profile_reasoning_write_refetches_session_effective_status_when_override_exists():
data = _run_reasoning_ui_script(
"""
responses.push({
reasoning_effort: 'low',
reasoning_scope: 'profile',
supported_efforts: ['none', 'medium', 'high'],
has_session_reasoning_override: false,
});
responses.push({
reasoning_effort: 'high',
reasoning_scope: 'session',
supported_efforts: ['none', 'medium', 'high'],
has_session_reasoning_override: true,
session_reasoning_effort: 'high',
profile_reasoning_effort: 'low',
});
await _sendReasoningEffort('profile', 'low');
return {
fetches,
label: nodes.composerReasoningLabel.textContent,
toast: toasts[toasts.length - 1],
};
"""
)
assert data["fetches"][0]["url"] == "/api/reasoning"
assert "session_id" not in json.loads(data["fetches"][0]["opts"]["body"])
assert "session_id=session-a" in data["fetches"][1]["url"]
assert data["label"].startswith("High")
assert data["label"].endswith("Session")
assert "session" in data["toast"]["msg"].lower()
def test_session_override_can_be_cleared_back_to_profile_default():
data = _run_reasoning_ui_script(
"""
responses.push({
reasoning_effort: 'high',
reasoning_scope: 'session',
supported_efforts: ['none', 'medium', 'high'],
has_session_reasoning_override: true,
session_reasoning_effort: 'high',
profile_reasoning_effort: 'medium',
});
syncReasoningChip();
await tick();
const clearLabels = dropdown.querySelectorAll('.reasoning-option').filter(opt => opt.dataset.clear).map(opt => opt.textContent);
responses.push({
reasoning_effort: 'medium',
reasoning_scope: 'profile',
supported_efforts: ['none', 'medium', 'high'],
has_session_reasoning_override: false,
session_reasoning_effort: null,
profile_reasoning_effort: 'medium',
});
await _clearSessionReasoningEffort();
const clearFetch = fetches[fetches.length - 1];
return {
clearLabels,
clearFetch,
label: nodes.composerReasoningLabel.textContent,
toast: toasts[toasts.length - 1],
clearSelected: dropdown.querySelectorAll('.reasoning-option').filter(opt => opt.dataset.clear && opt.classList.contains('selected')).length,
};
"""
)
assert data["clearLabels"] == ["Use profile default"]
assert data["clearFetch"]["url"] == "/api/session/reasoning"
body = json.loads(data["clearFetch"]["opts"]["body"])
assert body["effort"] == ""
assert body["session_id"] == "session-a"
assert data["label"] == "Medium"
assert "profile default" in data["toast"]["msg"].lower()
assert data["clearSelected"] == 1
def test_clear_action_requires_a_session():
data = _run_reasoning_ui_script(
"""
S.session = null;
const before = fetches.length;
await _clearSessionReasoningEffort();
return { before, after: fetches.length, toast: toasts[toasts.length - 1] };
"""
)
assert data["before"] == data["after"]
assert data["toast"]["type"] == "warning"
assert "Select a session" in data["toast"]["msg"]
def test_profile_write_under_active_override_reports_masked_save_and_highlights_both_groups():
data = _run_reasoning_ui_script(
"""
responses.push({
reasoning_effort: 'medium',
reasoning_scope: 'profile',
supported_efforts: ['none', 'medium', 'high'],
has_session_reasoning_override: false,
});
responses.push({
reasoning_effort: 'high',
reasoning_scope: 'session',
supported_efforts: ['none', 'medium', 'high'],
has_session_reasoning_override: true,
session_reasoning_effort: 'high',
profile_reasoning_effort: 'medium',
});
await _sendReasoningEffort('profile', 'medium');
return {
label: nodes.composerReasoningLabel.textContent,
toast: toasts[toasts.length - 1],
selectedScopes: dropdown.querySelectorAll('.reasoning-option').filter(opt => opt.classList.contains('selected') && !opt.dataset.clear).map(opt => opt.dataset.scope),
};
"""
)
msg = data["toast"]["msg"].lower()
assert "profile default saved: medium" in msg
assert "high session override" in msg
assert data["label"].startswith("High")
assert data["label"].endswith("Session")
assert "session" in data["selectedScopes"]
assert "profile" in data["selectedScopes"]
def test_reasoning_slash_status_stays_profile_global_with_active_session():
data = _run_reasoning_command_script(
"""
responses.push({
reasoning_effort: 'medium',
show_reasoning: true,
});
cmdReasoning('');
await tick();
return {
fetches,
toast: toasts[toasts.length - 1],
};
"""
)
assert data["fetches"][0]["url"] == "/api/reasoning"
assert "Reasoning effort: medium" in data["toast"]["msg"]
def test_reasoning_slash_write_refetches_active_session_chip_after_profile_save():
data = _run_reasoning_command_script(
"""
responses.push({
reasoning_effort: 'low',
reasoning_scope: 'profile',
supported_efforts: ['none', 'medium', 'high'],
has_session_reasoning_override: false,
});
responses.push({
reasoning_effort: 'high',
reasoning_scope: 'session',
supported_efforts: ['none', 'medium', 'high'],
has_session_reasoning_override: true,
session_reasoning_effort: 'high',
profile_reasoning_effort: 'low',
});
cmdReasoning('low');
await tick();
await tick();
return {
fetches,
label: nodes.composerReasoningLabel.textContent,
toast: toasts[toasts.length - 1],
};
"""
)
assert data["fetches"][0]["url"] == "/api/reasoning"
assert data["fetches"][1]["url"].endswith("session_id=session-a")
assert data["label"].startswith("High")
assert data["label"].endswith("Session")
assert "Reasoning effort: low" in data["toast"]["msg"]
+99
View File
@@ -375,6 +375,105 @@ def test_gateway_chat_worker_translates_sse_and_persists_session(tmp_path, monke
assert all(len(item) == 3 and item[2] for item in events)
def test_gateway_chat_worker_uses_session_reasoning_override(tmp_path, monkeypatch):
captured = {}
class FakeResponse:
status = 200
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def __iter__(self):
yield b'data: [DONE]\n\n'
def fake_urlopen(req, timeout=0):
captured["body"] = req.data.decode("utf-8")
return FakeResponse()
monkeypatch.setenv("HERMES_WEBUI_GATEWAY_BASE_URL", "http://gateway.local")
monkeypatch.setenv("HERMES_WEBUI_GATEWAY_API_KEY", "secret-token")
monkeypatch.setattr(gateway_chat.urllib.request, "urlopen", fake_urlopen)
s = new_session()
s.reasoning_effort = "high"
stream_id = "stream-gateway-reasoning-override"
s.active_stream_id = stream_id
s.pending_user_message = "Say hello"
s.pending_attachments = []
s.pending_started_at = 123
s.save()
STREAMS[stream_id] = create_stream_channel()
gateway_chat._run_gateway_chat_streaming(
s.session_id,
"Say hello",
"gpt-5",
str(tmp_path),
stream_id,
[],
)
payload = json.loads(captured["body"])
assert payload["reasoning_effort"] == "high"
def test_gateway_chat_worker_uses_session_profile_reasoning_default(tmp_path, monkeypatch):
captured = {}
class FakeResponse:
status = 200
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def __iter__(self):
yield b'data: [DONE]\n\n'
def fake_urlopen(req, timeout=0):
captured["body"] = req.data.decode("utf-8")
return FakeResponse()
profile_cfg = {
"model": {"default": "gpt-5", "provider": "openai"},
"agent": {"reasoning_effort": "high"},
}
monkeypatch.setenv("HERMES_WEBUI_GATEWAY_BASE_URL", "http://gateway.local")
monkeypatch.setenv("HERMES_WEBUI_GATEWAY_API_KEY", "secret-token")
monkeypatch.setattr(gateway_chat.urllib.request, "urlopen", fake_urlopen)
monkeypatch.setattr("api.config.get_config", lambda: {"agent": {"reasoning_effort": "medium"}})
monkeypatch.setattr("api.config.get_config_for_profile_home", lambda profile_home: profile_cfg)
monkeypatch.setattr("api.profiles.get_hermes_home_for_profile", lambda profile: tmp_path / str(profile or "default"))
s = new_session()
s.profile = "named-profile"
stream_id = "stream-gateway-profile-reasoning-default"
s.active_stream_id = stream_id
s.pending_user_message = "Say hello"
s.pending_attachments = []
s.pending_started_at = 123
s.save()
STREAMS[stream_id] = create_stream_channel()
gateway_chat._run_gateway_chat_streaming(
s.session_id,
"Say hello",
"gpt-5",
str(tmp_path),
stream_id,
[],
)
payload = json.loads(captured["body"])
assert payload["reasoning_effort"] == "high"
def test_gateway_chat_worker_preserves_reasoning_delta_whitespace_and_persists_reasoning(tmp_path, monkeypatch):
session_dir = tmp_path / "sessions"
session_dir.mkdir()