mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-16 12:40:18 +00:00
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:
+66
-19
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user