{"repo":"NousResearch/hermes-agent","public_id":"42187","display_label":"Codex gpt-5.5 autoraise notice deduplication fixes","summary":"These issues and pull requests address a Codex gpt-5.5 autoraise/auto-compaction notice that fires repeatedly on every agent init or gateway message instead of once per session. The work focuses on deduplicating or persisting the notice so it appears only once per session.","subsystem":"agent","counts":{"members":10,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":5,"merged_prs":1,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":59814},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-07-06T19:46:21+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.23555555555555552},"fix_edges":[{"issue_number":42187,"pr_number":59814,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":44439,"pr_number":59814,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":45817,"pr_number":59814,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":48621,"pr_number":59814,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":54432,"pr_number":59814,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":42187,"title":"fix: Show Codex gpt-5.5 autoraise notice once per gateway session","url":null,"state":"closed"},{"kind":"issue","number":45817,"title":"Codex gpt-5.5 autoraise notice fires on every agent init, not 'one-time' as docstring claims","url":null,"state":"closed"},{"kind":"issue","number":46786,"title":"Codex gpt-5.5 autoraise notice repeats every prompt — make it once-per-session","url":null,"state":"closed"},{"kind":"issue","number":47241,"title":"Codex gpt-5.5 auto-compaction info notice repeats on every gateway message","url":null,"state":"closed"},{"kind":"issue","number":54432,"title":"Deduplicate Codex gpt-5.5 autoraise notice without disabling autoraise","url":null,"state":"closed"},{"kind":"pr","number":42194,"title":"fix(gateway): persist Codex autoraise notice per session","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"fff240896","number":0},{"kind":"commit","value":"fff240896","number":0},{"kind":"pr","value":"#59814","number":59814},{"kind":"pr","value":"#42187","number":42187},{"kind":"pr","value":"#59814","number":59814}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":45622,"title":"fix(gateway): show Codex gpt-5.5 autoraise notice once per durable session (#42187)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"fff240896120645c2f0d2b559735cf69f34106a4","number":0},{"kind":"pr","value":"#42187","number":42187}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":45818,"title":"fix(compaction): make Codex gpt-5.5 autoraise notice actually one-time","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#59814","number":59814},{"kind":"pr","value":"#56893","number":56893}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":56893,"title":"fix(agent): dedupe Codex gpt-5.5 autoraise notice across agent inits","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"fff240896","number":0},{"kind":"pr","value":"#59814","number":59814},{"kind":"pr","value":"#54432","number":54432}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":59814,"title":"fix(compression): Codex autoraise cluster — gpt-5.4/spark coverage, never-lower, notice dedupe","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/agent_init.py","agent/auxiliary_client.py","hermes_cli/config.py","scripts/release.py","tests/agent/test_arcee_trinity_overrides.py","tests/agent/test_auxiliary_main_first.py","tests/agent/test_codex_gpt55_autoraise_notice.py","tests/run_agent/test_infinite_compaction_loop.py","tests/run_agent/test_plugin_context_engine_init.py","website/docs/developer-guide/context-compression-and-caching.md"],"fix_hunks":[{"file":"agent/agent_init.py","hunks":["@@ -68,24 +68,118 @@ def _ra():\n     return run_agent\n \n \n-def _build_codex_gpt55_autoraise_notice(autoraise: Dict[str, float]) -> str:\n-    \"\"\"Build the one-time notice shown when Codex gpt-5.5 raises compaction.\n+def _build_codex_gpt5_autoraise_notice(autoraise: Dict[str, Any]) -> str:\n+    \"\"\"Build the one-time notice shown when Codex gpt-5.x raises compaction.\n \n-    ``autoraise`` is ``{\"from\": <old_ratio>, \"to\": <new_ratio>}``. The same\n-    text is printed inline for CLI users and replayed via ``status_callback``\n-    for gateway users, so it must be self-contained and include the exact\n-    opt-back-out command.\n+    ``autoraise`` is ``{\"model\": <slug>, \"from\": <old_ratio>, \"to\": <new_ratio>}``.\n+    The same text is printed inline for CLI users and replayed via\n+    ``status_callback`` for gateway users, so it must be self-contained and\n+    include the exact opt-back-out command.\n     \"\"\"\n+    model = str(autoraise.get(\"model\") or \"gpt-5.4/5.5\").strip().lower().rsplit(\"/\", 1)[-1]\n+    # gpt-5.3-codex-spark has a native 128K window; the gpt-5.4/5.5 family is\n+    # capped at 272K by the Codex OAuth backend.\n+    cap = \"128K\" if model.startswith(\"gpt-5.3-codex-spark\") else \"272K\"\n     from_pct = int(round(autoraise[\"from\"] * 100))\n     to_pct = int(round(autoraise[\"to\"] * 100))\n     return (\n-        f\"ℹ Codex gpt-5.5 caps context at 272K, so auto-compaction was raised \"\n+        f\"ℹ Codex {model} caps context at {cap}, so auto-compaction was raised \"\n         f\"to {to_pct}% (from {from_pct}%) to use more of the window before \"\n         f\"summarizing.\\n\"\n         f\"  Opt back out: hermes config set compression.codex_gpt55_autoraise false\"\n     )\n \n \n+def _resolve_compression_threshold(\n+    global_threshold: float,\n+    model_cthresh: Optional[float],\n+    *,\n+    model: Optional[str] = None,\n+    is_codex_autoraise: bool,\n+) -> tuple[float, Optional[Dict[str, Any]]]:\n+    \"\"\"Combine the user's global compaction threshold with a per-model override.\n+\n+    Returns ``(effective_threshold, autoraise_notice)``. ``autoraise_notice`` is\n+    ``{\"model\": <slug>, \"from\": <old>, \"to\": <new>}`` only when a Codex\n+    autoraise (gpt-5.4/5.5 272K family or gpt-5.3-codex-spark) actually raises\n+    the threshold, otherwise ``None``.\n+\n+    The Codex overrides are *autoraises*: they must never LOWER a higher\n+    user-configured threshold. A user who already set ``compression.threshold``\n+    above the raised value deliberately keeps more raw context, and silently\n+    dropping them would both waste usable window and contradict the feature's\n+    purpose (use more of the window). Other overrides (e.g. Arcee Trinity)\n+    keep their existing unconditional behaviour.\n+    \"\"\"\n+    if model_cthresh is None:\n+        return global_threshold, None\n+    if is_codex_autoraise:\n+        if model_cthresh <= global_threshold + 1e-9:\n+            # Autoraise never lowers; keep the user's higher/equal threshold.\n+            return global_threshold, None\n+        return model_cthresh, {\n+            \"model\": model,\n+            \"from\": global_threshold,\n+            \"to\": model_cthresh,\n+        }\n+    return model_cthresh, None\n+\n+\n+def _codex_gpt55_autoraise_notice_marker():\n+    \"\"\"Path to the per-profile marker recording that the autoraise notice ran.\n+\n+    Lives under ``$HERMES_HOME`` (which is profile-scoped) alongside the other\n+    internal markers like ``.container-mode`` — so it is not a user-facing config\n+    key, and every profile tracks its own notice state independently.\n+    \"\"\"\n+    return get_hermes_home() / \".codex_gpt55_autoraise_notice\"\n+\n+\n+def _codex_gpt55_autoraise_notice_state(autoraise: Dict[str, Any]) -> str:\n+    \"\"\"Stable identity for one autoraise notice, keyed on what it displays.\n+\n+    Uses the model slug plus the same from→to percentages the notice text\n+    shows, so an unchanged threshold stays silent across restarts while a\n+    later change (the user edits their global ``threshold``, or switches to a\n+    different autoraised Codex model) re-notifies once.\n+    \"\"\"\n+    model = str(autoraise.get(\"model\") or \"\").strip().lower().rsplit(\"/\", 1)[-1]\n+    from_pct = int(round(float(autoraise[\"from\"]) * 100))\n+    to_pct = int(round(float(autoraise[\"to\"]) * 100))\n+    return f\"{model}:{from_pct}:{to_pct}\"\n+\n+\n+def _codex_gpt55_autoraise_notice_seen(autoraise: Dict[str, Any]) -> bool:\n+    \"\"\"True if this exact autoraise notice was already shown for this profile.\n+\n+    A missing/unreadable marker (or one recording a different threshold) reads\n+    as unseen, so the notice shows.\n+    \"\"\"\n+    try:\n+        current = _codex_gpt55_autoraise_notice_state(autoraise)\n+        return _codex_gpt55_autoraise_notice_marker().read_text(\n+            encoding=\"utf-8\"\n+        ).strip() == current\n+    except (OSError, KeyError, TypeError, ValueError):\n+        return False\n+\n+\n+def _record_codex_gpt55_autoraise_notice(autoraise: Dict[str, Any]) -> None:\n+    \"\"\"Persist that the autoraise notice was shown for this profile/config state.\n+\n+    Best-effort: a read-only or missing ``$HERMES_HOME`` just means the notice\n+    may show again next init, which is preferable to breaking agent init.\n+    \"\"\"\n+    try:\n+        marker = _codex_gpt55_autoraise_notice_marker()\n+        marker.parent.mkdir(parents=True, exist_ok=True)\n+        marker.write_text(\n+            _codex_gpt55_autoraise_notice_state(autoraise), encoding=\"utf-8\"\n+        )\n+    except (OSError, KeyError, TypeError, ValueError):\n+        pass\n+\n+\n def _normalized_custom_base_url(value: Any) -> str:\n     if not isinstance(value, str):\n         return \"\"","@@ -1409,14 +1503,14 @@ def _moa_reference_relay(event: str, **kwargs: Any) -> None:\n     if not isinstance(_compression_cfg, dict):\n         _compression_cfg = {}\n     compression_threshold = float(_compression_cfg.get(\"threshold\", 0.50))\n-    # Per-model/route compaction-threshold override. Codex gpt-5.5 raises to\n-    # 85% (the Codex backend caps the window at 272K, so the default 50% would\n-    # compact at ~136K — half the usable context). Gated by an opt-out config\n-    # flag so the user can fall back to the global threshold; when the override\n-    # fires we stash a one-time notification (replayed on the first turn) that\n-    # tells the user what changed and how to revert. The notice has its own\n-    # display gate so users can keep the threshold autoraise without getting\n-    # the banner on gateway turns.\n+    # Per-model/route compaction-threshold override. Codex gpt-5.4 / gpt-5.5\n+    # raise to 85% (the Codex backend caps both families at 272K, so the\n+    # default 50% would compact at ~136K — half the usable context). Gated by\n+    # an opt-out config flag so the user can fall back to the global threshold;\n+    # when the override fires we stash a one-time notification (replayed on the\n+    # first turn) that tells the user what changed and how to revert. The\n+    # notice has its own display gate so users can keep the threshold\n+    # autoraise without getting the banner on gateway turns.\n     _codex_gpt55_autoraise = str(\n         _compression_cfg.get(\"codex_gpt55_autoraise\", True)\n     ).lower() in {\"true\", \"1\", \"yes\"}","@@ -1427,28 +1521,30 @@ def _moa_reference_relay(event: str, **kwargs: Any) -> None:\n     try:\n         from agent.auxiliary_client import (\n             _compression_threshold_for_model as _cthresh_fn,\n-            _is_codex_gpt55 as _is_codex_gpt55_fn,\n+            _is_codex_gpt54_or_gpt55 as _is_codex_gpt54_or_gpt55_fn,\n+            _is_codex_spark as _is_codex_spark_fn,\n         )\n         _model_cthresh = _cthresh_fn(\n             agent.model,\n             agent.provider,\n             allow_codex_gpt55_autoraise=_codex_gpt55_autoraise,\n         )\n-        if _model_cthresh is not None:\n-            _prev_threshold = compression_threshold\n-            compression_threshold = _model_cthresh\n-            # Notify only for the Codex gpt-5.5 autoraise (the Arcee Trinity\n-            # override is a long-standing silent default). Skip the notice when\n-            # the user's global threshold already meets/exceeds the raised\n-            # value, since nothing actually changed for them.\n-            if (\n-                _is_codex_gpt55_fn(agent.model, agent.provider)\n-                and _model_cthresh > _prev_threshold + 1e-9\n-            ):\n-                agent._compression_threshold_autoraised = {\n-                    \"from\": _prev_threshold,\n-                    \"to\": _model_cthresh,\n-                }\n+        # The Codex autoraises (gpt-5.4/5.5 272K family and gpt-5.3-codex-spark)\n+        # apply only when they RAISE (never lower a user's higher global\n+        # threshold). The notice is populated only when it actually fires, and\n+        # carries the model slug so the banner names the right family. Arcee\n+        # Trinity keeps its long-standing unconditional behaviour.\n+        compression_threshold, agent._compression_threshold_autoraised = (\n+            _resolve_compression_threshold(\n+                compression_threshold,\n+                _model_cthresh,\n+                model=agent.model,\n+                is_codex_autoraise=(\n+                    _is_codex_gpt54_or_gpt55_fn(agent.model, agent.provider)\n+                    or _is_codex_spark_fn(agent.model, agent.provider)\n+                ),\n+            )\n+        )\n     except Exception:\n         pass\n     compression_enabled = str(_compression_cfg.get(\"enabled\", True)).lower() in {\"true\", \"1\", \"yes\"}","@@ -1677,6 +1773,12 @@ def _moa_reference_relay(event: str, **kwargs: Any) -> None:\n \n     if _selected_engine is not None:\n         agent.context_compressor = _selected_engine\n+        # External engines own compaction policy: the host compression\n+        # threshold (including the Codex gpt-5.5 autoraise above) only\n+        # configures the built-in ContextCompressor and never reaches the\n+        # plugin, so the autoraise notice would announce a change that does\n+        # not apply. Drop it. (#44439)\n+        agent._compression_threshold_autoraised = None\n         # Resolve context_length for plugin engines — mirrors switch_model() path\n         from agent.model_metadata import get_model_context_length\n         _plugin_ctx_len = get_model_context_length(","@@ -1899,29 +2001,53 @@ def _moa_reference_relay(event: str, **kwargs: Any) -> None:\n             agent._ollama_num_ctx,\n         )\n \n+    # Codex gpt-5.x autoraise notice: show at most once per profile/config\n+    # state. Without the persisted marker the notice re-fires on every agent\n+    # init — and the gateway rebuilds the agent per inbound message, so Discord\n+    # etc. saw it repeatedly (#54432). A change in the raised threshold (or the\n+    # autoraised model) updates the marker state and re-notifies once. The\n+    # config display gate (compression.codex_gpt55_autoraise_notice) still\n+    # suppresses the banner entirely without disabling the threshold autoraise.\n+    _autoraise = getattr(agent, \"_compression_threshold_autoraised\", None)\n+    _show_autoraise_notice = (\n+        bool(_autoraise)\n+        and compression_enabled\n+        and _codex_gpt55_autoraise_notice\n+        and not _codex_gpt55_autoraise_notice_seen(_autoraise)\n+    )\n+\n     if not agent.quiet_mode:\n         if compression_enabled:\n-            print(f\"📊 Context limit: {agent.context_compressor.context_length:,} tokens (compress at {int(compression_threshold*100)}% = {agent.context_compressor.threshold_tokens:,})\")\n+            # Report the active engine's own threshold — for a plugin engine\n+            # the host compression_threshold is not in effect, and mixing the\n+            # two printed a percent that contradicted the token count. (#44439)\n+            _active_threshold_pct = getattr(\n+                agent.context_compressor, \"threshold_percent\", compression_threshold\n+            )\n+            print(f\"📊 Context limit: {agent.context_compressor.context_length:,} tokens (compress at {int(_active_threshold_pct*100)}% = {agent.context_compressor.threshold_tokens:,})\")\n         else:\n             print(f\"📊 Context limit: {agent.context_compressor.context_length:,} tokens (auto-compression disabled)\")\n-        # One-time notice when the Codex gpt-5.5 autoraise kicked in, with the\n-        # exact opt-back-out command. Printed inline at startup for CLI users;\n-        # gateway users get the same text replayed via _compression_warning on\n-        # turn 1 (set below, after the warning slot is initialized).\n-        _autoraise = getattr(agent, \"_compression_threshold_autoraised\", None)\n-        if _autoraise and compression_enabled and _codex_gpt55_autoraise_notice:\n-            print(_build_codex_gpt55_autoraise_notice(_autoraise))\n+        # Notice with the exact opt-back-out command. Printed inline at startup\n+        # for CLI users; gateway users get the same text replayed via\n+        # _compression_warning on turn 1 (set below).\n+        if _show_autoraise_notice:\n+            print(_build_codex_gpt5_autoraise_notice(_autoraise))\n \n     # Check immediately so CLI users see the warning at startup.\n     # Gateway status_callback is not yet wired, so any warning is stored\n     # in _compression_warning and replayed in the first run_conversation().\n     agent._compression_warning = None\n-    # Gateway parity for the Codex gpt-5.5 autoraise notice: the startup print\n+    # Gateway parity for the Codex gpt-5.x autoraise notice: the startup print\n     # above only reaches the CLI, so stash the same text here to be replayed\n     # through status_callback on the first turn (Telegram/Discord/Slack/etc.).\n-    _autoraise = getattr(agent, \"_compression_threshold_autoraised\", None)\n-    if _autoraise and compression_enabled and _codex_gpt55_autoraise_notice:\n-        agent._compression_warning = _build_codex_gpt55_autoraise_notice(_autoraise)\n+    if _show_autoraise_notice:\n+        agent._compression_warning = _build_codex_gpt5_autoraise_notice(_autoraise)\n+\n+    # Mark shown so repeated inits in this profile (e.g. every gateway message)\n+    # stay silent. Recorded once, whether the notice went to the CLI print or\n+    # the gateway replay slot.\n+    if _show_autoraise_notice:\n+        _record_codex_gpt55_autoraise_notice(_autoraise)\n     # Lazy feasibility check: deferred to the first turn that approaches the\n     # compression threshold. Running it eagerly here costs ~400ms cold (network\n     # probe of the auxiliary provider chain + /models lookup) on every agent"]},{"file":"agent/auxiliary_client.py","hunks":["@@ -314,33 +314,64 @@ def _is_arcee_trinity_thinking(model: Optional[str]) -> bool:\n     return bare == \"trinity-large-thinking\"\n \n \n-# Context window enforced by ChatGPT's Codex OAuth backend for gpt-5.5.\n+# Context window enforced by ChatGPT's Codex OAuth backend for gpt-5.4/5.5.\n # The raw OpenAI API and OpenRouter expose 1.05M for the same slug, but the\n # Codex backend hard-caps at 272K (verified live: a ~330K-token request to\n # chatgpt.com/backend-api/codex/responses is rejected with\n # ``context_length_exceeded`` while ~250K succeeds). With a 272K ceiling the\n # default 50% compaction trigger fires at ~136K — wasteful, since the model\n # can hold far more raw context before summarization actually buys anything.\n-# We raise the trigger to 85% (~231K) on this exact route so Codex gpt-5.5\n-# sessions use the window they actually have.\n-_CODEX_GPT55_COMPACTION_THRESHOLD = 0.85\n+# We raise the trigger to 85% (~231K) on this exact route so Codex gpt-5.4/\n+# gpt-5.5 sessions use the window they actually have.\n+_CODEX_GPT54_GPT55_COMPACTION_THRESHOLD = 0.85\n \n+# gpt-5.3-codex-spark is Codex-OAuth-only (ChatGPT Pro entitlement) with a\n+# native 128K context window.  The default 50% compaction trigger fires at\n+# ~64K — wasting half the usable window, often before the session has enough\n+# turns to summarize meaningfully.  We raise the trigger to 70% (~90K) so\n+# spark sessions use more of the window before summarization, while still\n+# leaving ~38K headroom for the summary and continued conversation before\n+# the 128K hard limit.\n+_CODEX_SPARK_COMPACTION_THRESHOLD = 0.70\n \n-def _is_codex_gpt55(model: Optional[str], provider: Optional[str] = None) -> bool:\n-    \"\"\"True for gpt-5.5 accessed through the ChatGPT Codex OAuth backend.\n+\n+def _is_codex_gpt54_or_gpt55(model: Optional[str], provider: Optional[str] = None) -> bool:\n+    \"\"\"True for gpt-5.4 / gpt-5.5 on the ChatGPT Codex OAuth backend.\n \n     Matches only the Codex OAuth route (provider ``openai-codex``), not the\n     direct OpenAI API, OpenRouter, or GitHub Copilot paths — those expose a\n     larger context window for the same slug and must keep the user's default\n-    compaction threshold. ``gpt-5.5-pro`` and dated snapshots\n-    (``gpt-5.5-2026-04-23``) are matched via prefix so the override tracks the\n-    family without re-listing every variant.\n+    compaction threshold. ``gpt-5.4-pro`` / ``gpt-5.5-pro`` and dated snapshots\n+    are matched via prefix so the override tracks both 272K-capped families\n+    without re-listing every variant.\n     \"\"\"\n     prov = (provider or \"\").strip().lower()\n     if prov != \"openai-codex\":\n         return False\n     bare = (model or \"\").strip().lower().rsplit(\"/\", 1)[-1]\n-    return bare == \"gpt-5.5\" or bare.startswith(\"gpt-5.5-\") or bare.startswith(\"gpt-5.5.\")\n+    return (\n+        bare == \"gpt-5.4\"\n+        or bare.startswith(\"gpt-5.4-\")\n+        or bare.startswith(\"gpt-5.4.\")\n+        or bare == \"gpt-5.5\"\n+        or bare.startswith(\"gpt-5.5-\")\n+        or bare.startswith(\"gpt-5.5.\")\n+    )\n+\n+\n+def _is_codex_spark(model: Optional[str], provider: Optional[str] = None) -> bool:\n+    \"\"\"True for ``gpt-5.3-codex-spark`` on the ChatGPT Codex OAuth backend.\n+\n+    The model is Codex-OAuth-only (ChatGPT Pro entitlement) with a native\n+    128K context window.  Only the Codex OAuth route (provider\n+    ``openai-codex``) is matched — the slug is not available on other\n+    routes.\n+    \"\"\"\n+    prov = (provider or \"\").strip().lower()\n+    if prov != \"openai-codex\":\n+        return False\n+    bare = (model or \"\").strip().lower().rsplit(\"/\", 1)[-1]\n+    return bare == \"gpt-5.3-codex-spark\"\n \n \n def _fixed_temperature_for_model(","@@ -379,18 +410,26 @@ def _compression_threshold_for_model(\n \n     Per-model/route overrides:\n       - Arcee Trinity Large Thinking → 0.75 (preserve reasoning context).\n-      - gpt-5.5 on the Codex OAuth route → 0.85, because Codex caps the window\n-        at 272K and the default 50% trigger would compact at ~136K. Gated by\n-        ``allow_codex_gpt55_autoraise`` so the user can opt back down to the\n-        global default (the caller passes the config flag through here).\n+      - gpt-5.4 / gpt-5.5 on the Codex OAuth route → 0.85, because Codex caps\n+        both families at 272K and the default 50% trigger would compact at\n+        ~136K. Gated by ``allow_codex_gpt55_autoraise`` (historical config-key\n+        name kept for backward compatibility) so the user can opt back down to\n+        the global default (the caller passes the config flag through here).\n+      - gpt-5.3-codex-spark on the Codex OAuth route → 0.70, because the model\n+        has a native 128K window and the default 50% trigger would compact at\n+        ~64K — wasting half the usable context. Not gated by the gpt-5.5\n+        opt-out flag: 128K is the model's native window, so the raise is\n+        unambiguously correct.\n \n     Returns a float in (0, 1] to override the global ``compression.threshold``\n     config value, or ``None`` to leave the user's config value unchanged.\n     \"\"\"\n     if _is_arcee_trinity_thinking(model):\n         return 0.75\n-    if allow_codex_gpt55_autoraise and _is_codex_gpt55(model, provider):\n-        return _CODEX_GPT55_COMPACTION_THRESHOLD\n+    if allow_codex_gpt55_autoraise and _is_codex_gpt54_or_gpt55(model, provider):\n+        return _CODEX_GPT54_GPT55_COMPACTION_THRESHOLD\n+    if _is_codex_spark(model, provider):\n+        return _CODEX_SPARK_COMPACTION_THRESHOLD\n     return None\n \n # Default auxiliary models for direct API-key providers (cheap/fast for side tasks)","@@ -4151,7 +4190,15 @@ def resolve_provider_client(\n     # main_model also empty), the branches still hit their own\n     # missing-credentials returns and ``_resolve_auto`` falls through to\n     # the Step-2 chain as before.\n-    if not model:\n+    #\n+    # Prefer explicit caller model, then provider-scoped aux model, then main model.\n+    # Do NOT pre-fill a blank ``auto`` request from the config/main default here.\n+    # ``auto`` has its own main-runtime resolver below; pre-filling first can pair\n+    # a stale configured model with a live fallback provider (e.g. Claude model\n+    # sent to Codex after the main lane fell back to gpt-5.5). Let _resolve_auto()\n+    # return the actual current runtime model when the caller did not explicitly\n+    # request one. (# compression-current-model)\n+    if not model and provider != \"auto\":\n         model = _get_aux_model_for_provider(provider) or _read_main_model() or model\n \n     def _needs_codex_wrap(client_obj, base_url_str: str, model_str: str) -> bool:"]},{"file":"hermes_cli/config.py","hunks":["@@ -1362,17 +1362,18 @@ def _ensure_hermes_home_managed(home: Path):\n                                       # Default False matches historical behavior; set to\n                                       # True if you'd rather pause than silently lose\n                                       # context turns when your aux model is flaky.\n-        \"codex_gpt55_autoraise\": True,  # When True, gpt-5.5 on the ChatGPT Codex OAuth\n-                                      # route raises its compaction trigger to 85% (vs the\n-                                      # global `threshold` above). Codex hard-caps gpt-5.5\n-                                      # at a 272K window, so the default 50% would compact\n-                                      # at ~136K and waste half the usable context. Set to\n-                                      # False to opt back down to the global threshold\n-                                      # (e.g. 0.50) for Codex gpt-5.5 sessions. Only this\n-                                      # exact route is affected — gpt-5.5 on OpenAI's\n-                                      # direct API, OpenRouter, and Copilot keep the\n-                                      # global threshold regardless.\n-        \"codex_gpt55_autoraise_notice\": True,  # Display the one-time Codex gpt-5.5\n+        \"codex_gpt55_autoraise\": True,  # Historical key name kept for compatibility.\n+                                      # When True, gpt-5.4 / gpt-5.5 on the ChatGPT Codex\n+                                      # OAuth route raise their compaction trigger to 85%\n+                                      # (vs the global `threshold` above). Codex hard-caps\n+                                      # both families at a 272K window, so the default 50%\n+                                      # would compact at ~136K and waste half the usable\n+                                      # context. Set to False to opt back down to the global\n+                                      # threshold (e.g. 0.50) for those Codex sessions.\n+                                      # Only this exact route is affected — gpt-5.4 / 5.5\n+                                      # on OpenAI's direct API, OpenRouter, and Copilot keep\n+                                      # the global threshold regardless.\n+        \"codex_gpt55_autoraise_notice\": True,  # Display the one-time Codex gpt-5.4/5.5\n                                       # autoraise banner. Set False to keep the\n                                       # 85% threshold autoraise but suppress the\n                                       # user-facing notice in CLI/gateway output."]},{"file":"scripts/release.py","hunks":["@@ -285,6 +285,7 @@\n     \"[redacted:email]\": \"itsflownium\",\n     \"[redacted:email]\": \"dirtyren\",\n     \"[redacted:email]\": \"bigstar0920\",\n+    \"[redacted:email]\": \"tanmayxchoudhary\",\n     \"[redacted:email]\": \"AIalliAI\",\n     \"[redacted:email]\": \"waseemshahwan\",\n     \"[redacted:email]\": \"hellno\","]},{"file":"tests/agent/test_arcee_trinity_overrides.py","hunks":["@@ -13,11 +13,13 @@\n \n import pytest\n \n+from agent.agent_init import _resolve_compression_threshold\n from agent.auxiliary_client import (\n     _compression_threshold_for_model,\n     _fixed_temperature_for_model,\n     _is_arcee_trinity_thinking,\n-    _is_codex_gpt55,\n+    _is_codex_gpt54_or_gpt55,\n+    _is_codex_spark,\n )\n \n ","@@ -78,14 +80,14 @@ def test_compression_threshold_default_none_for_other_models() -> None:\n \n \n # ---------------------------------------------------------------------------\n-# Codex gpt-5.5 compaction-threshold autoraise\n+# Codex gpt-5.4 / gpt-5.5 compaction-threshold autoraise\n #\n-# ChatGPT's Codex OAuth backend caps gpt-5.5 at a 272K window (verified live:\n-# ~330K-token request rejected with context_length_exceeded, ~250K accepted).\n-# The default 50% compaction trigger would fire at ~136K — half the usable\n-# window — so this route raises the trigger to 85%. Only the Codex OAuth route\n-# is affected; the same slug on OpenAI direct / OpenRouter / Copilot exposes a\n-# larger window and keeps the user's global threshold.\n+# ChatGPT's Codex OAuth backend caps both families at a 272K window (verified\n+# live via the Codex /models resolver and per-slug fallback table). The default\n+# 50% compaction trigger would fire at ~136K — half the usable window — so this\n+# route raises the trigger to 85%. Only the Codex OAuth route is affected; the\n+# same slugs on OpenAI direct / OpenRouter / Copilot expose a larger window and\n+# keep the user's global threshold.\n # ---------------------------------------------------------------------------\n \n ","@@ -99,32 +101,40 @@ def test_compression_threshold_default_none_for_other_models() -> None:\n         \"openai/gpt-5.5\",  # aggregator-prefixed (still on the codex route)\n         \"GPT-5.5\",  # case-insensitive\n         \"  gpt-5.5  \",  # whitespace tolerant\n+        \"gpt-5.4\",  # base 5.4 (272K-capped)\n+        \"gpt-5.4-pro\",  # pro 5.4 variant (272K-capped)\n+        \"gpt-5.4-2026-01-01\",  # dated 5.4 snapshot\n+        \"openai/gpt-5.4\",  # aggregator-prefixed 5.4\n     ],\n )\n-def test_is_codex_gpt55_matches_on_codex_provider(model: str) -> None:\n-    assert _is_codex_gpt55(model, \"openai-codex\") is True\n+def test_is_codex_gpt54_or_gpt55_matches_on_codex_provider(model: str) -> None:\n+    assert _is_codex_gpt54_or_gpt55(model, \"openai-codex\") is True\n \n \n @pytest.mark.parametrize(\n     \"provider\",\n     [\"openrouter\", \"openai\", \"copilot\", \"openai-api\", \"\", None],\n )\n-def test_is_codex_gpt55_rejects_non_codex_providers(provider) -> None:\n-    # gpt-5.5 on any non-Codex route keeps the larger window — no override.\n-    assert _is_codex_gpt55(\"gpt-5.5\", provider) is False\n+def test_is_codex_gpt54_or_gpt55_rejects_non_codex_providers(provider) -> None:\n+    # gpt-5.4 / gpt-5.5 on any non-Codex route keep the larger window.\n+    assert _is_codex_gpt54_or_gpt55(\"gpt-5.5\", provider) is False\n+    assert _is_codex_gpt54_or_gpt55(\"gpt-5.4\", provider) is False\n \n \n @pytest.mark.parametrize(\n     \"model\",\n-    [\"gpt-5.4\", \"gpt-5\", \"gpt-5.55\", \"gpt-5.50\", \"\", None],\n+    [\"gpt-5\", \"gpt-5.55\", \"gpt-5.50\", \"gpt-5.45\", \"gpt-5.40\", \"\", None],\n )\n-def test_is_codex_gpt55_rejects_non_55_models(model) -> None:\n-    # gpt-5.55 / gpt-5.50 are different families and must NOT match — the\n-    # \"gpt-5.5-\" / \"gpt-5.5.\" prefix guards require a separator after \"5.5\".\n-    assert _is_codex_gpt55(model, \"openai-codex\") is False\n+def test_is_codex_gpt54_or_gpt55_rejects_non_54_55_models(model) -> None:\n+    # Close numeric neighbours must NOT match — the prefix guards require a\n+    # separator after \"5.4\" / \"5.5\" so e.g. gpt-5.45 and gpt-5.55 stay out.\n+    assert _is_codex_gpt54_or_gpt55(model, \"openai-codex\") is False\n \n \n def test_compression_threshold_for_codex_gpt55() -> None:\n+    assert _compression_threshold_for_model(\"gpt-5.4\", \"openai-codex\") == 0.85\n+    assert _compression_threshold_for_model(\"gpt-5.4-pro\", \"openai-codex\") == 0.85\n+    assert _compression_threshold_for_model(\"openai/gpt-5.4\", \"openai-codex\") == 0.85\n     assert _compression_threshold_for_model(\"gpt-5.5\", \"openai-codex\") == 0.85\n     assert _compression_threshold_for_model(\"gpt-5.5-pro\", \"openai-codex\") == 0.85\n     assert _compression_threshold_for_model(\"openai/gpt-5.5\", \"openai-codex\") == 0.85","@@ -132,14 +142,24 @@ def test_compression_threshold_for_codex_gpt55() -> None:\n \n def test_compression_threshold_codex_gpt55_other_routes_unaffected() -> None:\n     # Same slug, different route → no override (keep the user's config value).\n+    assert _compression_threshold_for_model(\"gpt-5.4\", \"openrouter\") is None\n+    assert _compression_threshold_for_model(\"gpt-5.4\", \"openai\") is None\n+    assert _compression_threshold_for_model(\"gpt-5.4\", \"copilot\") is None\n     assert _compression_threshold_for_model(\"gpt-5.5\", \"openrouter\") is None\n     assert _compression_threshold_for_model(\"gpt-5.5\", \"openai\") is None\n     assert _compression_threshold_for_model(\"gpt-5.5\", \"copilot\") is None\n+    assert _compression_threshold_for_model(\"openai/gpt-5.4\") is None  # no provider\n     assert _compression_threshold_for_model(\"openai/gpt-5.5\") is None  # no provider\n \n \n def test_compression_threshold_codex_gpt55_opt_out() -> None:\n-    # allow_codex_gpt55_autoraise=False reverts to the global default (None).\n+    # Historical flag name still governs both Codex families.\n+    assert (\n+        _compression_threshold_for_model(\n+            \"gpt-5.4\", \"openai-codex\", allow_codex_gpt55_autoraise=False\n+        )\n+        is None\n+    )\n     assert (\n         _compression_threshold_for_model(\n             \"gpt-5.5\", \"openai-codex\", allow_codex_gpt55_autoraise=False","@@ -157,3 +177,140 @@ def test_compression_threshold_opt_out_does_not_disable_trinity() -> None:\n         )\n         == 0.75\n     )\n+\n+\n+# ---------------------------------------------------------------------------\n+# Codex gpt-5.3-codex-spark compaction-threshold autoraise\n+#\n+# gpt-5.3-codex-spark is Codex-OAuth-only (ChatGPT Pro entitlement) with a\n+# native 128K context window.  The default 50% compaction trigger would fire\n+# at ~64K — wasting half the usable window, often before the session has\n+# accumulated enough turns to summarize meaningfully.  This route raises the\n+# trigger to 70% (~90K) to preserve more raw context while leaving ~38K\n+# headroom before the 128K hard limit.\n+# ---------------------------------------------------------------------------\n+\n+\n[redacted:email](\n+    \"model\",\n+    [\n+        \"gpt-5.3-codex-spark\",\n+        \"openai/gpt-5.3-codex-spark\",  # aggregator-prefixed (still on the codex route)\n+        \"GPT-5.3-CODEX-SPARK\",  # case-insensitive\n+        \"  gpt-5.3-codex-spark  \",  # whitespace tolerant\n+    ],\n+)\n+def test_is_codex_spark_matches_on_codex_provider(model: str) -> None:\n+    assert _is_codex_spark(model, \"openai-codex\") is True\n+\n+\n[redacted:email](\n+    \"provider\",\n+    [\"openrouter\", \"openai\", \"copilot\", \"openai-api\", \"\", None],\n+)\n+def test_is_codex_spark_rejects_non_codex_providers(provider) -> None:\n+    # spark on any non-Codex route is not a real slug — no override.\n+    assert _is_codex_spark(\"gpt-5.3-codex-spark\", provider) is False\n+\n+\n[redacted:email](\n+    \"model\",\n+    [\n+        \"gpt-5.5\",  # different family\n+        \"gpt-5.3-codex\",  # sibling, not spark\n+        \"gpt-5.3\",  # bare 5.3, not spark\n+        \"gpt-5.3-codex-spark-mini\",  # hypothetical variant — not matched yet\n+        \"\", None,\n+    ],\n+)\n+def test_is_codex_spark_rejects_non_spark_models(model) -> None:\n+    assert _is_codex_spark(model, \"openai-codex\") is False\n+\n+\n+def test_compression_threshold_for_codex_spark() -> None:\n+    assert _compression_threshold_for_model(\"gpt-5.3-codex-spark\", \"openai-codex\") == 0.70\n+    assert _compression_threshold_for_model(\"openai/gpt-5.3-codex-spark\", \"openai-codex\") == 0.70\n+\n+\n+def test_compression_threshold_codex_spark_other_routes_unaffected() -> None:\n+    # Same slug, different route → no override (keep the user's config value).\n+    assert _compression_threshold_for_model(\"gpt-5.3-codex-spark\", \"openrouter\") is None\n+    assert _compression_threshold_for_model(\"gpt-5.3-codex-spark\", \"openai\") is None\n+    assert _compression_threshold_for_model(\"gpt-5.3-codex-spark\") is None  # no provider\n+\n+\n+def test_compression_threshold_codex_spark_not_gated_by_gpt55_optout() -> None:\n+    # The spark autoraise is independent of the gpt-5.5 opt-out flag — 128K is\n+    # the model's native window, so 70% is unambiguously correct regardless of\n+    # whether the user opted out of the (artificial-cap) gpt-5.5 autoraise.\n+    assert (\n+        _compression_threshold_for_model(\n+            \"gpt-5.3-codex-spark\", \"openai-codex\", allow_codex_gpt55_autoraise=False\n+        )\n+        == 0.70\n+    )\n+\n+\n+# ── _resolve_compression_threshold (init_agent application logic) ────────────\n+#\n+# The Codex overrides are *autoraises*: they raise the trigger (0.85 for the\n+# gpt-5.4/5.5 272K family, 0.70 for spark) but must never LOWER a higher\n+# user-configured global threshold.\n+\n+\n+def test_resolve_codex_autoraise_raises_from_default() -> None:\n+    # Default 0.50 global → raised to 0.85, notice emitted.\n+    effective, notice = _resolve_compression_threshold(\n+        0.50, 0.85, model=\"gpt-5.5\", is_codex_autoraise=True\n+    )\n+    assert effective == 0.85\n+    assert notice == {\"model\": \"gpt-5.5\", \"from\": 0.50, \"to\": 0.85}\n+\n+\n+def test_resolve_codex_autoraise_never_lowers_higher_threshold() -> None:\n+    # Regression: a user who set compression.threshold above 0.85 must keep it.\n+    # The autoraise previously clobbered it down to 0.85 (and silently, since\n+    # the notice was suppressed when nothing \"raised\").\n+    effective, notice = _resolve_compression_threshold(\n+        0.90, 0.85, model=\"gpt-5.5\", is_codex_autoraise=True\n+    )\n+    assert effective == 0.90\n+    assert notice is None\n+\n+\n+def test_resolve_codex_spark_autoraise_never_lowers_higher_threshold() -> None:\n+    # Same never-lower contract for the spark autoraise (0.70).\n+    effective, notice = _resolve_compression_threshold(\n+        0.80, 0.70, model=\"gpt-5.3-codex-spark\", is_codex_autoraise=True\n+    )\n+    assert effective == 0.80\n+    assert notice is None\n+\n+\n+def test_resolve_codex_autoraise_equal_threshold_is_noop() -> None:\n+    # User already at exactly the raised value: keep it, no notice.\n+    effective, notice = _resolve_compression_threshold(\n+        0.85, 0.85, model=\"gpt-5.5\", is_codex_autoraise=True\n+    )\n+    assert effective == 0.85\n+    assert notice is None\n+\n+\n+def test_resolve_no_override_keeps_global() -> None:\n+    # No per-model override (model_cthresh is None) → global threshold, no notice.\n+    effective, notice = _resolve_compression_threshold(\n+        0.50, None, is_codex_autoraise=False\n+    )\n+    assert effective == 0.50\n+    assert notice is None\n+\n+\n+def test_resolve_non_codex_override_applies_unconditionally() -> None:\n+    # Arcee Trinity (0.75) keeps its long-standing unconditional behaviour: it\n+    # applies even when it lowers the user's global value, and never emits the\n+    # codex autoraise notice.\n+    effective, notice = _resolve_compression_threshold(\n+        0.90, 0.75, is_codex_autoraise=False\n+    )\n+    assert effective == 0.75\n+    assert notice is None"]},{"file":"tests/agent/test_auxiliary_main_first.py","hunks":["@@ -279,6 +279,34 @@ def test_runtime_override_wins_over_config(self, monkeypatch):\n         assert mock_resolve.call_args.args[0] == \"anthropic\"\n         assert mock_resolve.call_args.args[1] == \"runtime-model\"\n \n+    def test_resolve_provider_auto_returns_runtime_model_not_stale_config_default(self):\n+        \"\"\"Blank auto aux requests must not pair a stale config model with live fallback provider.\"\"\"\n+        runtime_client = MagicMock()\n+        with patch(\n+            \"agent.auxiliary_client._read_main_model\",\n+            return_value=\"claude-opus-4-8\",\n+        ) as mock_read_main_model, patch(\n+            \"agent.auxiliary_client._resolve_auto\",\n+            return_value=(runtime_client, \"gpt-5.5\"),\n+        ) as mock_resolve_auto:\n+            from agent.auxiliary_client import resolve_provider_client\n+\n+            client, model = resolve_provider_client(\n+                \"auto\",\n+                main_runtime={\n+                    \"provider\": \"openai-codex\",\n+                    \"model\": \"gpt-5.5\",\n+                    \"base_url\": \"\",\n+                    \"api_key\": \"\",\n+                    \"api_mode\": \"codex_responses\",\n+                },\n+            )\n+\n+        assert client is runtime_client\n+        assert model == \"gpt-5.5\"\n+        mock_read_main_model.assert_not_called()\n+        mock_resolve_auto.assert_called_once()\n+\n     def test_runtime_base_url_passed_for_named_api_key_provider(self):\n         \"\"\"Named API-key providers inherit the live session endpoint for aux work.\"\"\"\n         token_plan_url = \"[redacted:secret-assignment]\""]},{"file":"tests/agent/test_codex_gpt55_autoraise_notice.py","hunks":["@@ -1,4 +1,18 @@\n-\"\"\"Regression tests for the Codex gpt-5.5 autoraise notice gate.\"\"\"\n+\"\"\"Regression tests for the Codex gpt-5.x autoraise notice gate.\n+\n+Covers two layers:\n+\n+1. The config display gate (``compression.codex_gpt55_autoraise_notice``) —\n+   suppresses the banner without disabling the threshold autoraise.\n+2. The per-profile dedupe marker (#54432) — the notice must show at most once\n+   per profile/config state. Before the fix it re-fired on every agent init,\n+   and because the gateway rebuilds the agent per inbound message it spammed\n+   Discord etc. The gate persists a marker under ``$HERMES_HOME``\n+   (profile-scoped, isolated to a tempdir by the conftest autouse fixture)\n+   keyed on the model slug + displayed from→to percentages, so an unchanged\n+   threshold stays silent across restarts while a changed threshold (or a\n+   different autoraised Codex model) re-notifies once.\n+\"\"\"\n \n from __future__ import annotations\n ","@@ -6,9 +20,22 @@\n import io\n from pathlib import Path\n \n+import pytest\n+\n+from hermes_constants import get_hermes_home\n from hermes_state import SessionDB\n from run_agent import AIAgent\n \n+from agent.agent_init import (\n+    _codex_gpt55_autoraise_notice_marker,\n+    _codex_gpt55_autoraise_notice_seen,\n+    _codex_gpt55_autoraise_notice_state,\n+    _record_codex_gpt55_autoraise_notice,\n+)\n+\n+# The dict agent_init stashes when the Codex gpt-5.5 override fires.\n+AUTORAISE = {\"model\": \"gpt-5.5\", \"from\": 0.50, \"to\": 0.85}\n+\n \n def _config(*, show_notice: bool) -> dict:\n     return {","@@ -57,6 +84,9 @@ def _threshold_ratio(agent: AIAgent) -> float:\n     return round(compressor.threshold_tokens / compressor.context_length, 2)\n \n \n+# ── config display gate ──────────────────────────────────────────────────────\n+\n+\n def test_codex_gpt55_autoraise_notice_enabled_by_default(monkeypatch, tmp_path):\n     agent, stdout = _make_codex_agent(monkeypatch, tmp_path, show_notice=True)\n ","@@ -75,3 +105,113 @@ def test_codex_gpt55_autoraise_notice_can_be_suppressed_without_disabling_autora\n     assert _threshold_ratio(agent) == 0.85\n     assert getattr(agent, \"_compression_warning\") is None\n     assert \"auto-compaction was raised\" not in stdout\n+\n+\n+def test_codex_gpt55_autoraise_notice_deduped_across_agent_inits(monkeypatch, tmp_path):\n+    # Gateway spam scenario (#54432): the gateway rebuilds the agent per\n+    # inbound message. The first init shows the notice; the second stays\n+    # silent because the per-profile marker was recorded.\n+    agent1, stdout1 = _make_codex_agent(monkeypatch, tmp_path, show_notice=True)\n+    assert \"auto-compaction was raised\" in stdout1\n+    assert getattr(agent1, \"_compression_warning\") is not None\n+\n+    agent2, stdout2 = _make_codex_agent(monkeypatch, tmp_path, show_notice=True)\n+    assert _threshold_ratio(agent2) == 0.85  # autoraise still applies\n+    assert \"auto-compaction was raised\" not in stdout2\n+    assert getattr(agent2, \"_compression_warning\") is None\n+\n+\n+# ── per-profile dedupe marker (#54432) ───────────────────────────────────────\n+\n+\n+def test_marker_lives_under_hermes_home() -> None:\n+    marker = _codex_gpt55_autoraise_notice_marker()\n+    assert marker.parent == get_hermes_home()\n+    assert marker.name == \".codex_gpt55_autoraise_notice\"\n+\n+\n+def test_state_keyed_on_model_and_displayed_percentages() -> None:\n+    # Same percentages the notice text renders (int(round(ratio * 100))),\n+    # prefixed with the bare model slug.\n+    assert _codex_gpt55_autoraise_notice_state(AUTORAISE) == \"gpt-5.5:50:85\"\n+    assert (\n+        _codex_gpt55_autoraise_notice_state(\n+            {\"model\": \"openai/gpt-5.4\", \"from\": 0.75, \"to\": 0.85}\n+        )\n+        == \"gpt-5.4:75:85\"\n+    )\n+\n+\n+def test_unseen_before_anything_is_recorded() -> None:\n+    assert _codex_gpt55_autoraise_notice_seen(AUTORAISE) is False\n+\n+\n+def test_seen_after_record() -> None:\n+    assert _codex_gpt55_autoraise_notice_seen(AUTORAISE) is False\n+    _record_codex_gpt55_autoraise_notice(AUTORAISE)\n+    # A \"restart\" is just another call: the marker persists on disk.\n+    assert _codex_gpt55_autoraise_notice_seen(AUTORAISE) is True\n+\n+\n+def test_changed_threshold_renotifies_once() -> None:\n+    _record_codex_gpt55_autoraise_notice(AUTORAISE)\n+    assert _codex_gpt55_autoraise_notice_seen(AUTORAISE) is True\n+    # User raises their global threshold -> \"from\" changes -> notice re-fires.\n+    changed = {\"model\": \"gpt-5.5\", \"from\": 0.60, \"to\": 0.85}\n+    assert _codex_gpt55_autoraise_notice_seen(changed) is False\n+    _record_codex_gpt55_autoraise_notice(changed)\n+    assert _codex_gpt55_autoraise_notice_seen(changed) is True\n+    # And the old state is now considered unseen (marker moved forward).\n+    assert _codex_gpt55_autoraise_notice_seen(AUTORAISE) is False\n+\n+\n+def test_changed_model_renotifies_once() -> None:\n+    # Switching to a different autoraised Codex model re-fires the notice\n+    # (the banner names the model, so it displays new information).\n+    _record_codex_gpt55_autoraise_notice(AUTORAISE)\n+    other_model = {\"model\": \"gpt-5.4\", \"from\": 0.50, \"to\": 0.85}\n+    assert _codex_gpt55_autoraise_notice_seen(other_model) is False\n+    _record_codex_gpt55_autoraise_notice(other_model)\n+    assert _codex_gpt55_autoraise_notice_seen(other_model) is True\n+\n+\n+def test_record_is_idempotent() -> None:\n+    _record_codex_gpt55_autoraise_notice(AUTORAISE)\n+    _record_codex_gpt55_autoraise_notice(AUTORAISE)\n+    assert (\n+        _codex_gpt55_autoraise_notice_marker().read_text(encoding=\"utf-8\")\n+        == \"gpt-5.5:50:85\"\n+    )\n+\n+\n+def test_malformed_marker_reads_as_unseen() -> None:\n+    marker = _codex_gpt55_autoraise_notice_marker()\n+    marker.parent.mkdir(parents=True, exist_ok=True)\n+    marker.write_text(\"not-a-state\", encoding=\"utf-8\")\n+    assert _codex_gpt55_autoraise_notice_seen(AUTORAISE) is False\n+\n+\n[redacted:email](\"bad\", [{}, {\"from\": 0.5}, {\"from\": None, \"to\": None}])\n+def test_seen_tolerates_malformed_autoraise(bad) -> None:\n+    # Never raises even if the stashed dict is missing/garbage keys.\n+    assert _codex_gpt55_autoraise_notice_seen(bad) is False\n+\n+\n+def test_full_init_gate_shows_once_then_stays_silent() -> None:\n+    # Mirror the decision agent_init makes on each build:\n+    #   show = bool(autoraise) and compression_enabled and not seen(autoraise)\n+    def decide(compression_enabled: bool) -> bool:\n+        show = (\n+            bool(AUTORAISE)\n+            and compression_enabled\n+            and not _codex_gpt55_autoraise_notice_seen(AUTORAISE)\n+        )\n+        if show:\n+            _record_codex_gpt55_autoraise_notice(AUTORAISE)\n+        return show\n+\n+    # First init (any surface) shows; every subsequent init in this profile\n+    # stays silent — the gateway spam scenario from the issue.\n+    assert decide(compression_enabled=True) is True\n+    assert decide(compression_enabled=True) is False\n+    assert decide(compression_enabled=True) is False"]},{"file":"tests/run_agent/test_infinite_compaction_loop.py","hunks":["@@ -283,3 +283,58 @@ def test_no_cooldown_allows(self):\n         comp.last_prompt_tokens = [redacted:secret-assignment]\n         assert comp._summary_failure_cooldown_until == 0.0\n         assert comp.should_compress(65_000)\n+\n+\n+# ---------------------------------------------------------------------------\n+# Test: #48621 — gpt-5.3-codex-spark short-session boundary\n+#\n+# Issue #48621 Bug 2 claims that a short high-token session (15-20 messages,\n+# ~90k tokens on a 128k model with protect_last_n=20) hits\n+# compress_start >= compress_end, causing a silent context wipe.  The\n+# raw-budget fallback added in the #40803 fix already mitigates this: the\n+# boundary logic always exposes a minimal compressible window.  This test\n+# locks that behavior in for the exact #48621 parameters.\n+# ---------------------------------------------------------------------------\n+\n+class TestCodexSparkShortSessionBoundary:\n+    \"\"\"Verify that gpt-5.3-codex-spark's short-session scenario always yields\n+    a non-empty compressible window (no silent wipe).\"\"\"\n+\n+    def test_short_high_token_session_has_compressible_window(self):\n+        \"\"\"16 messages with large tool outputs on a 128k model must leave\n+        a compressible middle (compress_start < compress_end).\"\"\"\n+        comp = _make_compressor(\n+            model=\"gpt-5.3-codex-spark\",\n+            threshold_percent=0.70,\n+            protect_first_n=3,\n+            protect_last_n=20,\n+            config_context_length=128000,\n+        )\n+        # Build system + 3 head pairs + 3 tool groups (large outputs) + tail pair\n+        big_tool = \"x\" * 20000  # ~5k tokens each\n+        messages = [{\"role\": \"system\", \"content\": \"You are a helpful agent.\"}]\n+        for i in range(3):\n+            messages.append({\"role\": \"user\", \"content\": f\"Question {i}\"})\n+            messages.append({\"role\": \"assistant\", \"content\": f\"Answer {i}\"})\n+        for i in range(3):\n+            messages.append({\"role\": \"user\", \"content\": f\"Run command {i}\"})\n+            messages.append({\n+                \"role\": \"assistant\", \"content\": \"\",\n+                \"tool_calls\": [{\n+                    \"id\": f\"tc{i}\", \"type\": \"function\",\n+                    \"function\": {\"name\": \"terminal\", \"arguments\": \"{}\"},\n+                }],\n+            })\n+            messages.append({\"role\": \"tool\", \"tool_call_id\": f\"tc{i}\", \"content\": big_tool})\n+        messages.append({\"role\": \"user\", \"content\": \"Final question\"})\n+        messages.append({\"role\": \"assistant\", \"content\": \"Final answer\"})\n+\n+        head = comp._protect_head_size(messages)\n+        compress_start = comp._align_boundary_forward(messages, head)\n+        compress_end = comp._find_tail_cut_by_tokens(messages, compress_start)\n+\n+        assert compress_start < compress_end, (\n+            f\"No compressible window: start={compress_start}, end={compress_end}. \"\n+            f\"This would cause the silent context wipe described in #48621.\"\n+        )\n+        assert comp.has_content_to_compress(messages) is True"]},{"file":"tests/run_agent/test_plugin_context_engine_init.py","hunks":["@@ -139,3 +139,101 @@ def test_plugin_engine_update_model_args():\n     assert \"model\" in kw\n     assert \"provider\" in kw\n     assert \"api_mode\" in kw\n+\n+\n+def _codex_agent_kwargs():\n+    return dict(\n+        model=\"gpt-5.5\",\n+        provider=\"openai-codex\",\n+        api_key=\"[redacted:secret-assignment]\",\n+        base_url=\"https://chatgpt.com/backend-api/codex\",\n+        quiet_mode=True,\n+        skip_context_files=True,\n+        skip_memory=True,\n+    )\n+\n+\n+def test_codex_gpt55_autoraise_suppressed_for_plugin_engine():\n+    \"\"\"Codex gpt-5.5 autoraise must not fire when an external engine is active.\n+\n+    Regression test for #44439 — the host compression threshold (including\n+    the 0.85 autoraise) never reaches a plugin context engine, so the notice\n+    announced a change that did not apply.\n+    \"\"\"\n+    engine = _StubEngine()\n+    cfg = {\n+        \"context\": {\"engine\": \"stub\"},\n+        \"compression\": {\"enabled\": True, \"threshold\": 0.75},\n+        \"agent\": {},\n+    }\n+\n+    with (\n+        patch(\"hermes_cli.config.load_config\", return_value=cfg),\n+        patch(\"plugins.context_engine.load_context_engine\", return_value=engine),\n+        patch(\"agent.model_metadata.get_model_context_length\", return_value=272_000),\n+        patch(\"run_agent.get_tool_definitions\", return_value=[]),\n+        patch(\"run_agent.check_toolset_requirements\", return_value={}),\n+        patch(\"run_agent.OpenAI\"),\n+    ):\n+        from run_agent import AIAgent\n+\n+        agent = AIAgent(**_codex_agent_kwargs())\n+\n+    assert agent.context_compressor is engine\n+    assert agent._compression_threshold_autoraised is None\n+    assert agent._compression_warning is None\n+    # The engine's own policy is untouched by the host threshold.\n+    assert engine.threshold_percent == 0.75\n+\n+\n+def test_codex_gpt55_autoraise_still_applies_to_builtin_compressor():\n+    \"\"\"Stock built-in compressor keeps the 50% → 85% Codex gpt-5.5 autoraise.\"\"\"\n+    cfg = {\n+        \"compression\": {\"enabled\": True, \"threshold\": 0.50},\n+        \"agent\": {},\n+    }\n+\n+    with (\n+        patch(\"hermes_cli.config.load_config\", return_value=cfg),\n+        patch(\"agent.context_compressor.get_model_context_length\", return_value=272_000),\n+        patch(\"run_agent.get_tool_definitions\", return_value=[]),\n+        patch(\"run_agent.check_toolset_requirements\", return_value={}),\n+        patch(\"run_agent.OpenAI\"),\n+    ):\n+        from run_agent import AIAgent\n+\n+        agent = AIAgent(**_codex_agent_kwargs())\n+\n+    assert agent._compression_threshold_autoraised == {\"model\": \"gpt-5.5\", \"from\": 0.50, \"to\": 0.85}\n+    assert agent.context_compressor.threshold_percent == 0.85\n+    # Gateway parity: the notice is stashed for replay on turn 1.\n+    assert agent._compression_warning and \"85%\" in agent._compression_warning\n+\n+\n+def test_codex_gpt55_autoraise_applies_when_plugin_engine_missing():\n+    \"\"\"If the configured engine fails to load, the built-in compressor is\n+    active and the autoraise (plus its notice) must still apply.\"\"\"\n+    cfg = {\n+        \"context\": {\"engine\": \"no-such-engine\"},\n+        \"compression\": {\"enabled\": True, \"threshold\": 0.50},\n+        \"agent\": {},\n+    }\n+\n+    with (\n+        patch(\"hermes_cli.config.load_config\", return_value=cfg),\n+        patch(\n+            \"plugins.context_engine.load_context_engine\",\n+            side_effect=ValueError(\"not found\"),\n+        ),\n+        patch(\"hermes_cli.plugins.get_plugin_context_engine\", return_value=None),\n+        patch(\"agent.context_compressor.get_model_context_length\", return_value=272_000),\n+        patch(\"run_agent.get_tool_definitions\", return_value=[]),\n+        patch(\"run_agent.check_toolset_requirements\", return_value={}),\n+        patch(\"run_agent.OpenAI\"),\n+    ):\n+        from run_agent import AIAgent\n+\n+        agent = AIAgent(**_codex_agent_kwargs())\n+\n+    assert agent._compression_threshold_autoraised == {\"model\": \"gpt-5.5\", \"from\": 0.50, \"to\": 0.85}\n+    assert agent.context_compressor.threshold_percent == 0.85"]},{"file":"website/docs/developer-guide/context-compression-and-caching.md","hunks":["@@ -113,9 +113,13 @@ The ChatGPT Codex OAuth backend hard-caps gpt-5.5 at a **272K** context window\n GitHub Copilot). At the default 50% trigger, compaction would fire at ~136K —\n half the window the model can actually use. When the active route is Codex\n OAuth (`provider: openai-codex`) and the model is gpt-5.5, Hermes raises the\n-trigger to **85%** (~231K) and prints a one-time notice with the opt-out\n-command. Only this exact route is affected; gpt-5.5 on any other provider keeps\n-your global `threshold`. To opt back down to the global value:\n+trigger to **85%** (~231K) and shows a notice with the opt-out command. The\n+notice is shown once per profile — a marker under `$HERMES_HOME`\n+(`.codex_gpt55_autoraise_notice`) records that it ran, so repeated agent/session\n+inits (e.g. every inbound gateway message) don't re-emit it; if the raised\n+threshold later changes it re-notifies once. Only this exact route is affected;\n+gpt-5.5 on any other provider keeps your global `threshold`. To opt back down to\n+the global value:\n \n ```bash\n hermes config set compression.codex_gpt55_autoraise false\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}