fix: session-scoped /fast + full /new reset to config defaults (#67979)
* fix(fast): default /fast to session scope on CLI and gateway Completes the session-first policy from #67946 for the /fast toggle (the remaining half of #54084). A bare /fast fast|normal now applies to the current session only; --global persists agent.service_tier to config.yaml. Gateway: new _session_service_tier_overrides dict (registered in _CONVERSATION_SCOPED_STATE so /new clears it) resolved at both agent turn sites via _resolve_session_service_tier(); the /fast handler and its choice picker apply session overrides and evict the cached agent. The TUI config.set fast path was already session-scoped. CLI: /fast parses --global (parity with /reasoning); bare toggles mutate self.service_tier only. * fix(sessions): /new resets model, reasoning, and fast to config defaults /new and /reset are full conversation boundaries: session-scoped runtime overrides do not carry into the next session (#48055, #23131). CLI new_session(): clears the one-turn model restore, re-derives service_tier from config, and — when the session's model differs from the config default — switches back via the shared switch_model() pipeline (live agent swap included; best-effort so an unreachable default never blocks /new). TUI _reset_session_agent(): stops forwarding model_override / create_reasoning_override / create_service_tier_override into the rebuilt agent and pops the pins so later rebuilds can't resurrect them. The gateway already cleared its per-session overrides via _clear_conversation_scope on /new. Cross-session contamination stays impossible: nothing here touches process-global env or other sessions' pins.
This commit is contained in:
parent
3441b80f4f
commit
47fb20c0bd
62
cli.py
62
cli.py
|
|
@ -7154,6 +7154,68 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
|||
self.reasoning_config = _parse_reasoning_config(
|
||||
CLI_CONFIG["agent"].get("reasoning_effort", "")
|
||||
)
|
||||
# /new is a full conversation boundary: session-scoped runtime
|
||||
# overrides (/model --session, /fast, one-turn restores) do not carry
|
||||
# forward. Re-derive model/provider and service tier from config.yaml
|
||||
# so a session-only switch never leaks into the next session (#48055,
|
||||
# #23131).
|
||||
self._pending_one_turn_model_restore = None
|
||||
self.service_tier = _parse_service_tier_config(
|
||||
CLI_CONFIG["agent"].get("service_tier", "")
|
||||
)
|
||||
_model_config = CLI_CONFIG.get("model", {})
|
||||
_config_model = (
|
||||
(_model_config.get("default") or _model_config.get("model") or "")
|
||||
if isinstance(_model_config, dict)
|
||||
else (_model_config or "")
|
||||
)
|
||||
if _config_model and _config_model != getattr(self, "model", None):
|
||||
_config_provider = (
|
||||
_model_config.get("provider", "")
|
||||
if isinstance(_model_config, dict)
|
||||
else ""
|
||||
)
|
||||
try:
|
||||
from hermes_cli.model_switch import switch_model as _switch_model
|
||||
|
||||
_reset_result = _switch_model(
|
||||
raw_input=_config_model,
|
||||
current_provider=self.provider or "",
|
||||
current_model=self.model or "",
|
||||
current_base_url=self.base_url or "",
|
||||
current_api_key=self.api_key or "",
|
||||
is_global=False,
|
||||
explicit_provider=_config_provider or "",
|
||||
)
|
||||
if _reset_result.success:
|
||||
if self.agent:
|
||||
self.agent.switch_model(
|
||||
new_model=_reset_result.new_model,
|
||||
new_provider=_reset_result.target_provider,
|
||||
api_key=_reset_result.api_key,
|
||||
base_url=_reset_result.base_url,
|
||||
api_mode=_reset_result.api_mode,
|
||||
)
|
||||
self.model = _reset_result.new_model
|
||||
self.provider = _reset_result.target_provider
|
||||
self.requested_provider = _reset_result.target_provider
|
||||
self._explicit_api_key = _reset_result.api_key
|
||||
self._explicit_base_url = _reset_result.base_url
|
||||
if _reset_result.api_key:
|
||||
self.api_key = _reset_result.api_key
|
||||
if _reset_result.base_url:
|
||||
self.base_url = _reset_result.base_url
|
||||
if _reset_result.api_mode:
|
||||
self.api_mode = _reset_result.api_mode
|
||||
if not silent:
|
||||
_cprint(
|
||||
f" (model reset to config default: "
|
||||
f"{_reset_result.new_model})"
|
||||
)
|
||||
except Exception:
|
||||
# Best-effort: an unreachable config default must never block
|
||||
# /new. The session keeps the current working model.
|
||||
logger.debug("/new model reset to config default failed", exc_info=True)
|
||||
_sync_process_session_id(self.session_id)
|
||||
|
||||
if self.agent:
|
||||
|
|
|
|||
|
|
@ -2031,6 +2031,7 @@ _CONVERSATION_SCOPED_STATE: tuple = (
|
|||
"_session_model_overrides",
|
||||
"_pending_one_turn_model_restores",
|
||||
"_session_reasoning_overrides",
|
||||
"_session_service_tier_overrides",
|
||||
"_pending_model_notes",
|
||||
"_last_resolved_model",
|
||||
"_queued_events",
|
||||
|
|
@ -3055,6 +3056,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
_session_model_overrides: Dict[str, Dict[str, str]] = {}
|
||||
_pending_one_turn_model_restores: Dict[str, Dict[str, Any]] = {}
|
||||
_session_reasoning_overrides: Dict[str, Dict[str, Any]] = {}
|
||||
_session_service_tier_overrides: Dict[str, Optional[str]] = {}
|
||||
_pending_turn_sidecar_notes: Dict[str, List[str]] = {}
|
||||
_session_ephemeral_pin: Dict[str, tuple] = {}
|
||||
_session_vc_last: Dict[str, str] = {}
|
||||
|
|
@ -3264,6 +3266,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
# Per-session reasoning effort overrides from /reasoning.
|
||||
# Key: session_key, Value: parsed reasoning config dict.
|
||||
self._session_reasoning_overrides: Dict[str, Dict[str, Any]] = {}
|
||||
# Per-session fast-mode overrides from /fast.
|
||||
# Key: session_key, Value: "priority" or None (explicit normal).
|
||||
self._session_service_tier_overrides: Dict[str, Optional[str]] = {}
|
||||
# Per-turn must-deliver notes relocated out of the ephemeral system
|
||||
# prompt (auto-reset note, first-contact intro, voice-channel change).
|
||||
# Staged by _handle_message_with_agent, consumed once by run_sync and
|
||||
|
|
@ -5238,6 +5243,52 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
else:
|
||||
self._session_reasoning_overrides[session_key] = dict(reasoning_config)
|
||||
|
||||
def _resolve_session_service_tier(
|
||||
self,
|
||||
source=None,
|
||||
session_key: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""Resolve the effective service tier for a session.
|
||||
|
||||
A session-scoped /fast override wins over the config default. The
|
||||
override dict stores "priority" or None (explicit normal), so key
|
||||
presence — not value truthiness — decides whether it applies.
|
||||
"""
|
||||
resolved_session_key = session_key
|
||||
if not resolved_session_key and source is not None:
|
||||
try:
|
||||
resolved_session_key = self._session_key_for_source(source)
|
||||
except Exception:
|
||||
resolved_session_key = None
|
||||
|
||||
overrides = getattr(self, "_session_service_tier_overrides", {}) or {}
|
||||
if resolved_session_key and resolved_session_key in overrides:
|
||||
return overrides[resolved_session_key]
|
||||
return self._load_service_tier()
|
||||
|
||||
def _set_session_service_tier_override(
|
||||
self,
|
||||
session_key: str,
|
||||
service_tier,
|
||||
clear: bool = False,
|
||||
) -> None:
|
||||
"""Set or clear the session-scoped /fast override.
|
||||
|
||||
``service_tier`` is "priority" or None (explicit normal). Pass
|
||||
``clear=True`` to remove the override entirely (fall back to config).
|
||||
"""
|
||||
if not session_key:
|
||||
return
|
||||
if "_session_service_tier_overrides" not in self.__dict__:
|
||||
# Force an instance-level dict: the class attribute is a shared
|
||||
# default for partially-constructed test runners, and mutating it
|
||||
# would leak overrides across runner instances.
|
||||
self._session_service_tier_overrides = {}
|
||||
if clear:
|
||||
self._session_service_tier_overrides.pop(session_key, None)
|
||||
else:
|
||||
self._session_service_tier_overrides[session_key] = service_tier
|
||||
|
||||
@staticmethod
|
||||
def _load_service_tier() -> str | None:
|
||||
"""Load Priority Processing setting from config.yaml.
|
||||
|
|
@ -14702,7 +14753,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
source=source, model=model
|
||||
)
|
||||
self._reasoning_config = reasoning_config
|
||||
self._service_tier = self._load_service_tier()
|
||||
self._service_tier = self._resolve_session_service_tier(source=source)
|
||||
turn_route = self._resolve_turn_agent_config(prompt, model, runtime_kwargs)
|
||||
|
||||
# Enrich the prompt with image descriptions so the background
|
||||
|
|
@ -19917,7 +19968,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
model=model,
|
||||
)
|
||||
self._reasoning_config = reasoning_config
|
||||
self._service_tier = self._load_service_tier()
|
||||
self._service_tier = self._resolve_session_service_tier(
|
||||
source=source, session_key=session_key
|
||||
)
|
||||
# Set up stream consumer for token streaming or interim commentary.
|
||||
_stream_consumer = None
|
||||
_stream_delta_cb = None
|
||||
|
|
|
|||
|
|
@ -3108,43 +3108,64 @@ class GatewaySlashCommandsMixin:
|
|||
return out
|
||||
|
||||
async def _handle_fast_command(self, event: MessageEvent) -> Optional[str]:
|
||||
"""Handle /fast — mirror the CLI Priority Processing toggle in gateway chats."""
|
||||
"""Handle /fast — mirror the CLI Priority Processing toggle in gateway chats.
|
||||
|
||||
Session-scoped by default; ``--global`` persists agent.service_tier
|
||||
to config.yaml (parity with /model and /reasoning).
|
||||
"""
|
||||
from gateway.run import _load_gateway_config, _resolve_gateway_model
|
||||
from hermes_cli.models import model_supports_fast_mode
|
||||
|
||||
args = event.get_command_args().strip().lower()
|
||||
self._service_tier = self._load_service_tier()
|
||||
raw_args = event.get_command_args().strip().lower()
|
||||
# Reuse the /reasoning arg parser: strips --global (any position),
|
||||
# normalizes unicode dashes.
|
||||
args, persist_global = self._parse_reasoning_command_args(raw_args)
|
||||
session_key = self._session_key_for_source(event.source)
|
||||
self._service_tier = self._resolve_session_service_tier(
|
||||
session_key=session_key
|
||||
)
|
||||
|
||||
user_config = _load_gateway_config()
|
||||
model = _resolve_gateway_model(user_config)
|
||||
if not model_supports_fast_mode(model):
|
||||
return t("gateway.fast.not_supported")
|
||||
|
||||
def _apply_fast_selection(value: str) -> str:
|
||||
def _apply_fast_selection(value: str, persist: bool = False) -> str:
|
||||
"""Apply a /fast argument (typed or picked) and return the reply."""
|
||||
if value in {"fast", "on"}:
|
||||
self._service_tier = "priority"
|
||||
tier = "priority"
|
||||
saved_value = "fast"
|
||||
label = t("gateway.fast.label_fast")
|
||||
elif value in {"normal", "off"}:
|
||||
self._service_tier = None
|
||||
tier = None
|
||||
saved_value = "normal"
|
||||
label = t("gateway.fast.label_normal")
|
||||
else:
|
||||
return t("gateway.fast.unknown_arg", arg=value)
|
||||
if self._save_gateway_config_key("agent.service_tier", saved_value):
|
||||
return t("gateway.fast.saved", label=label)
|
||||
self._service_tier = tier
|
||||
if persist:
|
||||
if self._save_gateway_config_key("agent.service_tier", saved_value):
|
||||
# Global write supersedes any session override.
|
||||
self._set_session_service_tier_override(
|
||||
session_key, None, clear=True
|
||||
)
|
||||
self._evict_cached_agent(session_key)
|
||||
return t("gateway.fast.saved", label=label)
|
||||
# Config write failed — fall back to a session override so the
|
||||
# user's choice still applies (mirrors /reasoning --global).
|
||||
self._set_session_service_tier_override(session_key, tier)
|
||||
self._evict_cached_agent(session_key)
|
||||
return t("gateway.fast.session_only", label=label)
|
||||
self._set_session_service_tier_override(session_key, tier)
|
||||
self._evict_cached_agent(session_key)
|
||||
return t("gateway.fast.session_only", label=label)
|
||||
|
||||
if not args or args == "status":
|
||||
is_fast = self._service_tier == "priority"
|
||||
status = t("gateway.fast.status_fast") if is_fast else t("gateway.fast.status_normal")
|
||||
|
||||
# Interactive picker on platforms that support it.
|
||||
session_key = self._session_key_for_source(event.source)
|
||||
|
||||
async def _on_fast_choice(_chat_id: str, value: str) -> str:
|
||||
return _apply_fast_selection(value)
|
||||
return _apply_fast_selection(value, persist=persist_global)
|
||||
|
||||
picker_sent = await self._try_send_choice_picker(
|
||||
event,
|
||||
|
|
@ -3169,7 +3190,7 @@ class GatewaySlashCommandsMixin:
|
|||
|
||||
return t("gateway.fast.status", mode=status)
|
||||
|
||||
return _apply_fast_selection(args)
|
||||
return _apply_fast_selection(args, persist=persist_global)
|
||||
|
||||
async def _handle_yolo_command(self, event: MessageEvent) -> Union[str, EphemeralReply]:
|
||||
"""Handle /yolo — toggle dangerous command approval bypass for this session only."""
|
||||
|
|
|
|||
|
|
@ -2653,7 +2653,11 @@ class CLICommandsMixin:
|
|||
_cprint(f" {_ACCENT}✓ Busy input mode set to '{arg}' (session only){_RST}")
|
||||
|
||||
def _handle_fast_command(self, cmd: str):
|
||||
"""Handle /fast — toggle fast mode (OpenAI Priority Processing / Anthropic Fast Mode)."""
|
||||
"""Handle /fast — toggle fast mode (OpenAI Priority Processing / Anthropic Fast Mode).
|
||||
|
||||
Session-scoped by default; ``--global`` persists agent.service_tier
|
||||
to config.yaml (parity with /model and /reasoning).
|
||||
"""
|
||||
from cli import _ACCENT, _DIM, _RST, _cprint, save_config_value
|
||||
if not self._fast_command_available():
|
||||
_cprint(" (._.) /fast is only available for models that support fast mode (OpenAI Priority Processing or Anthropic Fast Mode).")
|
||||
|
|
@ -2672,10 +2676,15 @@ class CLICommandsMixin:
|
|||
if len(parts) < 2 or parts[1].strip().lower() == "status":
|
||||
status = "fast" if self.service_tier == "priority" else "normal"
|
||||
_cprint(f" {_ACCENT}{feature_name}: {status}{_RST}")
|
||||
_cprint(f" {_DIM}Usage: /fast [normal|fast|status]{_RST}")
|
||||
_cprint(f" {_DIM}Usage: /fast [normal|fast|status] [--global]{_RST}")
|
||||
return
|
||||
|
||||
arg = parts[1].strip().lower()
|
||||
arg_tokens = parts[1].strip().lower().split()
|
||||
explicit_global = "--global" in arg_tokens
|
||||
arg = " ".join(
|
||||
token for token in arg_tokens
|
||||
if token not in ("--global", "--session")
|
||||
)
|
||||
|
||||
if arg in {"fast", "on"}:
|
||||
self.service_tier = "priority"
|
||||
|
|
@ -2687,14 +2696,16 @@ class CLICommandsMixin:
|
|||
label = "NORMAL"
|
||||
else:
|
||||
_cprint(f" {_DIM}(._.) Unknown argument: {arg}{_RST}")
|
||||
_cprint(f" {_DIM}Usage: /fast [normal|fast|status]{_RST}")
|
||||
_cprint(f" {_DIM}Usage: /fast [normal|fast|status] [--global]{_RST}")
|
||||
return
|
||||
|
||||
self.agent = None # Force agent re-init with new service-tier config
|
||||
if save_config_value("agent.service_tier", saved_value):
|
||||
if explicit_global and save_config_value("agent.service_tier", saved_value):
|
||||
_cprint(f" {_ACCENT}✓ {feature_name} set to {label} (saved to config){_RST}")
|
||||
elif explicit_global:
|
||||
_cprint(f" {_ACCENT}✓ {feature_name} set to {label} (session only; config save failed){_RST}")
|
||||
else:
|
||||
_cprint(f" {_ACCENT}✓ {feature_name} set to {label} (session only){_RST}")
|
||||
_cprint(f" {_ACCENT}✓ {feature_name} set to {label} (this session — use --global to persist){_RST}")
|
||||
|
||||
def _handle_debug_command(self, cmd_original: str = ""):
|
||||
"""Handle /debug — upload debug report + logs and print share URLs.
|
||||
|
|
|
|||
|
|
@ -156,8 +156,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
|||
args_hint="[level|show|hide|full|clamp] [--global]",
|
||||
subcommands=("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra", "show", "hide", "on", "off", "full", "clamp", "--global")),
|
||||
CommandDef("fast", "Toggle fast mode — OpenAI Priority Processing / Anthropic Fast Mode (Normal/Fast)", "Configuration",
|
||||
args_hint="[normal|fast|status]",
|
||||
subcommands=("normal", "fast", "status", "on", "off")),
|
||||
args_hint="[normal|fast|status] [--global]",
|
||||
subcommands=("normal", "fast", "status", "on", "off", "--global")),
|
||||
CommandDef("skin", "Show or change the display skin/theme", "Configuration",
|
||||
cli_only=True, args_hint="[name]"),
|
||||
CommandDef("indicator", "Pick the TUI busy-indicator style", "Configuration",
|
||||
|
|
|
|||
|
|
@ -83,6 +83,20 @@ class TestHandleFastCommand(unittest.TestCase):
|
|||
):
|
||||
cli_mod.HermesCLI._handle_fast_command(stub, "/fast normal")
|
||||
|
||||
# Session-scoped by default: no config write.
|
||||
mock_save.assert_not_called()
|
||||
self.assertIsNone(stub.service_tier)
|
||||
self.assertIsNone(stub.agent)
|
||||
|
||||
def test_global_flag_persists_service_tier(self):
|
||||
cli_mod = _import_cli()
|
||||
stub = self._make_cli(service_tier="priority")
|
||||
with (
|
||||
patch.object(cli_mod, "_cprint"),
|
||||
patch.object(cli_mod, "save_config_value", return_value=True) as mock_save,
|
||||
):
|
||||
cli_mod.HermesCLI._handle_fast_command(stub, "/fast normal --global")
|
||||
|
||||
mock_save.assert_called_once_with("agent.service_tier", "normal")
|
||||
self.assertIsNone(stub.service_tier)
|
||||
self.assertIsNone(stub.agent)
|
||||
|
|
|
|||
|
|
@ -220,6 +220,63 @@ class TestHandleReasoningCommand(unittest.TestCase):
|
|||
self.assertEqual(agent.reasoning_config, {"enabled": True, "effort": "medium"})
|
||||
agent.reset_session_state.assert_called_once()
|
||||
|
||||
def test_new_session_resets_service_tier_and_model_from_config(self):
|
||||
"""/new re-derives service tier and model from config.yaml — session
|
||||
/fast and /model switches do not carry forward (#48055, #23131)."""
|
||||
from cli import CLI_CONFIG, HermesCLI
|
||||
|
||||
agent = SimpleNamespace(
|
||||
reasoning_config=None,
|
||||
reset_session_state=MagicMock(),
|
||||
switch_model=MagicMock(),
|
||||
)
|
||||
stub = SimpleNamespace(
|
||||
agent=agent,
|
||||
conversation_history=[],
|
||||
session_id="old-session",
|
||||
_session_db=None,
|
||||
_pending_title=None,
|
||||
_resumed=False,
|
||||
reasoning_config=None,
|
||||
_notify_session_boundary=MagicMock(),
|
||||
# Session had switched to fast + a session-only model.
|
||||
service_tier="priority",
|
||||
_pending_one_turn_model_restore={"model": "stale"},
|
||||
model="session-switched-model",
|
||||
provider="openrouter",
|
||||
requested_provider="openrouter",
|
||||
api_key="k",
|
||||
base_url="",
|
||||
api_mode="",
|
||||
)
|
||||
|
||||
fake_result = SimpleNamespace(
|
||||
success=True,
|
||||
new_model="config-default-model",
|
||||
target_provider="openrouter",
|
||||
api_key="k2",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
api_mode="chat_completions",
|
||||
)
|
||||
with patch.dict(
|
||||
CLI_CONFIG.setdefault("agent", {}),
|
||||
{"reasoning_effort": "medium", "service_tier": "normal"},
|
||||
), patch.dict(
|
||||
CLI_CONFIG,
|
||||
{"model": {"default": "config-default-model", "provider": "openrouter"}},
|
||||
), patch(
|
||||
"hermes_cli.model_switch.switch_model", return_value=fake_result
|
||||
):
|
||||
HermesCLI.new_session(stub, silent=True)
|
||||
|
||||
# Fast override cleared back to config default (normal → None).
|
||||
self.assertIsNone(stub.service_tier)
|
||||
# One-turn restore snapshot cleared.
|
||||
self.assertIsNone(stub._pending_one_turn_model_restore)
|
||||
# Model reset to the config default via the live agent swap.
|
||||
self.assertEqual(stub.model, "config-default-model")
|
||||
agent.switch_model.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reasoning extraction and result dict
|
||||
|
|
|
|||
|
|
@ -189,7 +189,8 @@ class TestFastChoicePicker:
|
|||
assert values == ["fast", "normal"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fast_picker_selection_persists_service_tier(self, tmp_path, monkeypatch):
|
||||
async def test_fast_picker_selection_is_session_scoped(self, tmp_path, monkeypatch):
|
||||
"""A bare /fast picker tap applies a session override, not a config write."""
|
||||
self._patch_fast_support(monkeypatch, tmp_path)
|
||||
adapter = _PickerAdapter()
|
||||
runner = _make_runner(adapter)
|
||||
|
|
@ -199,6 +200,22 @@ class TestFastChoicePicker:
|
|||
on_choice = adapter.calls[0]["on_choice_selected"]
|
||||
await on_choice(event.source.chat_id, "fast")
|
||||
|
||||
assert runner._service_tier == "priority"
|
||||
assert runner._session_service_tier_overrides
|
||||
assert not (tmp_path / "config.yaml").exists()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fast_picker_global_flag_persists_service_tier(self, tmp_path, monkeypatch):
|
||||
"""A /fast --global picker tap persists agent.service_tier to config."""
|
||||
self._patch_fast_support(monkeypatch, tmp_path)
|
||||
adapter = _PickerAdapter()
|
||||
runner = _make_runner(adapter)
|
||||
event = _make_event("/fast --global")
|
||||
|
||||
await runner._handle_fast_command(event)
|
||||
on_choice = adapter.calls[0]["on_choice_selected"]
|
||||
await on_choice(event.source.chat_id, "fast")
|
||||
|
||||
assert runner._service_tier == "priority"
|
||||
saved = yaml.safe_load((tmp_path / "config.yaml").read_text(encoding="utf-8"))
|
||||
assert saved["agent"]["service_tier"] == "fast"
|
||||
|
|
|
|||
|
|
@ -143,7 +143,8 @@ def test_turn_route_skips_priority_processing_for_unsupported_models():
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_fast_command_persists_config(monkeypatch, tmp_path):
|
||||
async def test_handle_fast_command_session_scoped_by_default(monkeypatch, tmp_path):
|
||||
"""Bare /fast fast applies a session override — config.yaml untouched."""
|
||||
runner = _make_runner()
|
||||
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||
|
|
@ -152,11 +153,57 @@ async def test_handle_fast_command_persists_config(monkeypatch, tmp_path):
|
|||
|
||||
response = await runner._handle_fast_command(_make_event("/fast fast"))
|
||||
|
||||
assert "FAST" in response
|
||||
assert runner._service_tier == "priority"
|
||||
# Session override recorded; config.yaml NOT written.
|
||||
assert runner._session_service_tier_overrides
|
||||
assert not (tmp_path / "config.yaml").exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_fast_command_global_flag_persists_config(monkeypatch, tmp_path):
|
||||
runner = _make_runner()
|
||||
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||
monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {})
|
||||
monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda config=None: "gpt-5.4")
|
||||
|
||||
response = await runner._handle_fast_command(_make_event("/fast fast --global"))
|
||||
|
||||
assert "FAST" in response
|
||||
assert runner._service_tier == "priority"
|
||||
|
||||
saved = yaml.safe_load((tmp_path / "config.yaml").read_text(encoding="utf-8"))
|
||||
assert saved["agent"]["service_tier"] == "fast"
|
||||
# Global write supersedes the session override.
|
||||
assert not runner._session_service_tier_overrides
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_fast_override_beats_config_default(monkeypatch, tmp_path):
|
||||
"""A session /fast normal wins over agent.service_tier: fast in config."""
|
||||
runner = _make_runner()
|
||||
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||
monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {})
|
||||
monkeypatch.setattr(
|
||||
gateway_run,
|
||||
"_load_gateway_runtime_config",
|
||||
lambda: {"agent": {"service_tier": "fast"}},
|
||||
)
|
||||
monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda config=None: "gpt-5.4")
|
||||
|
||||
event = _make_event("/fast normal")
|
||||
session_key = runner._session_key_for_source(event.source)
|
||||
|
||||
response = await runner._handle_fast_command(event)
|
||||
|
||||
assert "NORMAL" in response
|
||||
# Override stores explicit None (normal) and wins over config "fast".
|
||||
assert session_key in runner._session_service_tier_overrides
|
||||
assert runner._resolve_session_service_tier(session_key=session_key) is None
|
||||
# A different session still gets the config default.
|
||||
assert runner._resolve_session_service_tier(session_key="other-session") == "priority"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
|
|
@ -10459,16 +10459,20 @@ def test_start_agent_build_passes_session_model_override(
|
|||
# ── billing/subscription state + error serialization ─────────────────
|
||||
|
||||
|
||||
def test_reset_session_agent_preserves_explicit_normal_fast(monkeypatch):
|
||||
def test_reset_session_agent_clears_session_overrides(monkeypatch):
|
||||
"""/new is a full conversation boundary: session-scoped /model, /reasoning,
|
||||
and /fast overrides do NOT carry into the fresh agent — it re-derives
|
||||
everything from config.yaml (#48055, #23131)."""
|
||||
captured = {}
|
||||
new_agent = types.SimpleNamespace(model="openai/gpt-5.4", service_tier="")
|
||||
session = _session(
|
||||
agent=types.SimpleNamespace(
|
||||
model="openai/gpt-5.4",
|
||||
reasoning_config=None,
|
||||
reasoning_config={"enabled": True, "effort": "high"},
|
||||
service_tier="",
|
||||
),
|
||||
model_override={"model": "openai/gpt-5.4"},
|
||||
create_reasoning_override={"enabled": True, "effort": "high"},
|
||||
create_service_tier_override="",
|
||||
)
|
||||
|
||||
|
|
@ -10488,7 +10492,14 @@ def test_reset_session_agent_preserves_explicit_normal_fast(monkeypatch):
|
|||
|
||||
server._reset_session_agent("sid", session)
|
||||
|
||||
assert captured["service_tier_override"] == ""
|
||||
# No session overrides forwarded — fresh agent builds from config.
|
||||
assert "model_override" not in captured
|
||||
assert "reasoning_config_override" not in captured
|
||||
assert "service_tier_override" not in captured
|
||||
# And the session pins are gone so a later rebuild can't resurrect them.
|
||||
assert "model_override" not in session
|
||||
assert "create_reasoning_override" not in session
|
||||
assert "create_service_tier_override" not in session
|
||||
assert session["agent"] is new_agent
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -4765,25 +4765,22 @@ def _preview_restart_callbacks(parent: str, task_id: str) -> dict:
|
|||
def _reset_session_agent(sid: str, session: dict) -> dict:
|
||||
tokens = _set_session_context(session["session_key"])
|
||||
try:
|
||||
# Preserve this session's chosen model AND reasoning across /new so a
|
||||
# reset doesn't silently revert to global config (or to a model
|
||||
# another session set). See the cross-session-contamination note in
|
||||
# _apply_model_switch.
|
||||
reset_kw = {"model_override": session.get("model_override")}
|
||||
old_reasoning = getattr(session.get("agent"), "reasoning_config", None)
|
||||
if old_reasoning is None:
|
||||
old_reasoning = session.get("create_reasoning_override")
|
||||
if isinstance(old_reasoning, dict):
|
||||
reset_kw["reasoning_config_override"] = old_reasoning
|
||||
create_service_tier_override = session.get("create_service_tier_override")
|
||||
if create_service_tier_override is not None:
|
||||
reset_kw["service_tier_override"] = create_service_tier_override
|
||||
# /new is a full conversation boundary: session-scoped runtime
|
||||
# overrides (/model, /reasoning, /fast) do NOT carry forward — the
|
||||
# fresh agent re-derives model/provider, reasoning, and service tier
|
||||
# from config.yaml (#48055, #23131). Session pins are cleared below so
|
||||
# a rebuild can't resurrect them. (Global process state is still never
|
||||
# touched — see the cross-session-contamination note in
|
||||
# _apply_model_switch.)
|
||||
session.pop("model_override", None)
|
||||
session.pop("create_reasoning_override", None)
|
||||
session.pop("create_service_tier_override", None)
|
||||
session.pop("one_turn_model_restore", None)
|
||||
new_agent = _make_agent(
|
||||
sid,
|
||||
session["session_key"],
|
||||
session_id=session["session_key"],
|
||||
platform_override=_session_source(session),
|
||||
**reset_kw,
|
||||
)
|
||||
finally:
|
||||
_clear_session_context(tokens)
|
||||
|
|
|
|||
Loading…
Reference in New Issue