diff --git a/api/config.py b/api/config.py index d840d1d30..33a44cd4c 100644 --- a/api/config.py +++ b/api/config.py @@ -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): diff --git a/api/gateway_chat.py b/api/gateway_chat.py index b0bc7aaa7..42275598a 100644 --- a/api/gateway_chat.py +++ b/api/gateway_chat.py @@ -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 diff --git a/api/models.py b/api/models.py index 386ccbfe6..422744d69 100644 --- a/api/models.py +++ b/api/models.py @@ -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 diff --git a/api/routes.py b/api/routes.py index 68234068b..d80f1b778 100644 --- a/api/routes.py +++ b/api/routes.py @@ -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)), diff --git a/api/streaming.py b/api/streaming.py index 6eeb38926..d9dfa8d76 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -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 `) 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, diff --git a/static/commands.js b/static/commands.js index c1d6dc54e..7cddc5592 100644 --- a/static/commands.js +++ b/static/commands.js @@ -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)); diff --git a/static/style.css b/static/style.css index c5d0e6e38..4f304e014 100644 --- a/static/style.css +++ b/static/style.css @@ -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);} diff --git a/static/ui.js b/static/ui.js index 6eb819823..a411184d6 100644 --- a/static/ui.js +++ b/static/ui.js @@ -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(); } } diff --git a/tests/test_2697_session_reasoning_effort.py b/tests/test_2697_session_reasoning_effort.py new file mode 100644 index 000000000..bd0cab273 --- /dev/null +++ b/tests/test_2697_session_reasoning_effort.py @@ -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"] diff --git a/tests/test_webui_gateway_chat_backend.py b/tests/test_webui_gateway_chat_backend.py index fd5c31761..b7d1ae1b3 100644 --- a/tests/test_webui_gateway_chat_backend.py +++ b/tests/test_webui_gateway_chat_backend.py @@ -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()