diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index be02c2aedd1..1ed366d2439 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -325,6 +325,15 @@ def _is_arcee_trinity_thinking(model: Optional[str]) -> bool: # gpt-5.5 sessions use the window they actually have. _CODEX_GPT54_GPT55_COMPACTION_THRESHOLD = 0.85 +# gpt-5.3-codex-spark is Codex-OAuth-only (ChatGPT Pro entitlement) with a +# native 128K context window. The default 50% compaction trigger fires at +# ~64K — wasting half the usable window, often before the session has enough +# turns to summarize meaningfully. We raise the trigger to 70% (~90K) so +# spark sessions use more of the window before summarization, while still +# leaving ~38K headroom for the summary and continued conversation before +# the 128K hard limit. +_CODEX_SPARK_COMPACTION_THRESHOLD = 0.70 + def _is_codex_gpt54_or_gpt55(model: Optional[str], provider: Optional[str] = None) -> bool: """True for gpt-5.4 / gpt-5.5 on the ChatGPT Codex OAuth backend. @@ -350,6 +359,21 @@ def _is_codex_gpt54_or_gpt55(model: Optional[str], provider: Optional[str] = Non ) +def _is_codex_spark(model: Optional[str], provider: Optional[str] = None) -> bool: + """True for ``gpt-5.3-codex-spark`` on the ChatGPT Codex OAuth backend. + + The model is Codex-OAuth-only (ChatGPT Pro entitlement) with a native + 128K context window. Only the Codex OAuth route (provider + ``openai-codex``) is matched — the slug is not available on other + routes. + """ + prov = (provider or "").strip().lower() + if prov != "openai-codex": + return False + bare = (model or "").strip().lower().rsplit("/", 1)[-1] + return bare == "gpt-5.3-codex-spark" + + def _fixed_temperature_for_model( model: Optional[str], base_url: Optional[str] = None, @@ -391,6 +415,9 @@ def _compression_threshold_for_model( ~136K. Gated by ``allow_codex_gpt55_autoraise`` (historical config-key name kept for backward compatibility) so the user can opt back down to the global default (the caller passes the config flag through here). + - gpt-5.3-codex-spark on the Codex OAuth route → 0.70, because the model + has a native 128K window and the default 50% trigger would compact at + ~64K — wasting half the usable context. Gated by the same flag. Returns a float in (0, 1] to override the global ``compression.threshold`` config value, or ``None`` to leave the user's config value unchanged. @@ -399,6 +426,8 @@ def _compression_threshold_for_model( return 0.75 if allow_codex_gpt55_autoraise and _is_codex_gpt54_or_gpt55(model, provider): return _CODEX_GPT54_GPT55_COMPACTION_THRESHOLD + if allow_codex_gpt55_autoraise and _is_codex_spark(model, provider): + return _CODEX_SPARK_COMPACTION_THRESHOLD return None # Default auxiliary models for direct API-key providers (cheap/fast for side tasks) diff --git a/tests/agent/test_arcee_trinity_overrides.py b/tests/agent/test_arcee_trinity_overrides.py index 965e9150a6c..edb1f7250b5 100644 --- a/tests/agent/test_arcee_trinity_overrides.py +++ b/tests/agent/test_arcee_trinity_overrides.py @@ -18,6 +18,7 @@ from agent.auxiliary_client import ( _fixed_temperature_for_model, _is_arcee_trinity_thinking, _is_codex_gpt54_or_gpt55, + _is_codex_spark, ) @@ -175,3 +176,75 @@ def test_compression_threshold_opt_out_does_not_disable_trinity() -> None: ) == 0.75 ) + + +# --------------------------------------------------------------------------- +# Codex gpt-5.3-codex-spark compaction-threshold autoraise +# +# gpt-5.3-codex-spark is Codex-OAuth-only (ChatGPT Pro entitlement) with a +# native 128K context window. The default 50% compaction trigger would fire +# at ~64K — wasting half the usable window, often before the session has +# accumulated enough turns to summarize meaningfully. This route raises the +# trigger to 70% (~90K) to preserve more raw context while leaving ~38K +# headroom before the 128K hard limit. +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "model", + [ + "gpt-5.3-codex-spark", + "openai/gpt-5.3-codex-spark", # aggregator-prefixed (still on the codex route) + "GPT-5.3-CODEX-SPARK", # case-insensitive + " gpt-5.3-codex-spark ", # whitespace tolerant + ], +) +def test_is_codex_spark_matches_on_codex_provider(model: str) -> None: + assert _is_codex_spark(model, "openai-codex") is True + + +@pytest.mark.parametrize( + "provider", + ["openrouter", "openai", "copilot", "openai-api", "", None], +) +def test_is_codex_spark_rejects_non_codex_providers(provider) -> None: + # spark on any non-Codex route is not a real slug — no override. + assert _is_codex_spark("gpt-5.3-codex-spark", provider) is False + + +@pytest.mark.parametrize( + "model", + [ + "gpt-5.5", # different family + "gpt-5.3-codex", # sibling, not spark + "gpt-5.3", # bare 5.3, not spark + "gpt-5.3-codex-spark-mini", # hypothetical variant — not matched yet + "", None, + ], +) +def test_is_codex_spark_rejects_non_spark_models(model) -> None: + assert _is_codex_spark(model, "openai-codex") is False + + +def test_compression_threshold_for_codex_spark() -> None: + assert _compression_threshold_for_model("gpt-5.3-codex-spark", "openai-codex") == 0.70 + assert _compression_threshold_for_model("openai/gpt-5.3-codex-spark", "openai-codex") == 0.70 + + +def test_compression_threshold_codex_spark_other_routes_unaffected() -> None: + # Same slug, different route → no override (keep the user's config value). + assert _compression_threshold_for_model("gpt-5.3-codex-spark", "openrouter") is None + assert _compression_threshold_for_model("gpt-5.3-codex-spark", "openai") is None + assert _compression_threshold_for_model("gpt-5.3-codex-spark") is None # no provider + + +def test_compression_threshold_codex_spark_not_gated_by_gpt55_optout() -> None: + # The spark autoraise is independent of the gpt-5.5 opt-out flag — 128K is + # the model's native window, so 70% is unambiguously correct regardless of + # whether the user opted out of the (artificial-cap) gpt-5.5 autoraise. + assert ( + _compression_threshold_for_model( + "gpt-5.3-codex-spark", "openai-codex", allow_codex_gpt55_autoraise=False + ) + == 0.70 + ) diff --git a/tests/run_agent/test_infinite_compaction_loop.py b/tests/run_agent/test_infinite_compaction_loop.py index fc26a2f4199..31a7a12ba16 100644 --- a/tests/run_agent/test_infinite_compaction_loop.py +++ b/tests/run_agent/test_infinite_compaction_loop.py @@ -283,3 +283,58 @@ class TestCooldownGuard: comp.last_prompt_tokens = 65_000 assert comp._summary_failure_cooldown_until == 0.0 assert comp.should_compress(65_000) + + +# --------------------------------------------------------------------------- +# Test: #48621 — gpt-5.3-codex-spark short-session boundary +# +# Issue #48621 Bug 2 claims that a short high-token session (15-20 messages, +# ~90k tokens on a 128k model with protect_last_n=20) hits +# compress_start >= compress_end, causing a silent context wipe. The +# raw-budget fallback added in the #40803 fix already mitigates this: the +# boundary logic always exposes a minimal compressible window. This test +# locks that behavior in for the exact #48621 parameters. +# --------------------------------------------------------------------------- + +class TestCodexSparkShortSessionBoundary: + """Verify that gpt-5.3-codex-spark's short-session scenario always yields + a non-empty compressible window (no silent wipe).""" + + def test_short_high_token_session_has_compressible_window(self): + """16 messages with large tool outputs on a 128k model must leave + a compressible middle (compress_start < compress_end).""" + comp = _make_compressor( + model="gpt-5.3-codex-spark", + threshold_percent=0.70, + protect_first_n=3, + protect_last_n=20, + config_context_length=128000, + ) + # Build system + 3 head pairs + 3 tool groups (large outputs) + tail pair + big_tool = "x" * 20000 # ~5k tokens each + messages = [{"role": "system", "content": "You are a helpful agent."}] + for i in range(3): + messages.append({"role": "user", "content": f"Question {i}"}) + messages.append({"role": "assistant", "content": f"Answer {i}"}) + for i in range(3): + messages.append({"role": "user", "content": f"Run command {i}"}) + messages.append({ + "role": "assistant", "content": "", + "tool_calls": [{ + "id": f"tc{i}", "type": "function", + "function": {"name": "terminal", "arguments": "{}"}, + }], + }) + messages.append({"role": "tool", "tool_call_id": f"tc{i}", "content": big_tool}) + messages.append({"role": "user", "content": "Final question"}) + messages.append({"role": "assistant", "content": "Final answer"}) + + head = comp._protect_head_size(messages) + compress_start = comp._align_boundary_forward(messages, head) + compress_end = comp._find_tail_cut_by_tokens(messages, compress_start) + + assert compress_start < compress_end, ( + f"No compressible window: start={compress_start}, end={compress_end}. " + f"This would cause the silent context wipe described in #48621." + ) + assert comp.has_content_to_compress(messages) is True