diff --git a/agent/turn_context.py b/agent/turn_context.py index f3f5e46b21fe..59914befe0c7 100644 --- a/agent/turn_context.py +++ b/agent/turn_context.py @@ -720,9 +720,21 @@ def build_turn_context( tools=agent.tools or None, ) _compressor = agent.context_compressor - agent._turn_preflight_display_snapshot = ( - _compressor.snapshot_preflight_display_tokens() + # getattr guard: minimal compressor doubles (SimpleNamespace in the + # engine-preflight tests) and plugin context engines lack this + # ContextCompressor-only method — absence means no snapshot, and the + # finalizer's rollback stays disarmed for the turn (display-only). + _snapshot_fn = getattr( + _compressor, "snapshot_preflight_display_tokens", None ) + if callable(_snapshot_fn): + _snapshot_val = _snapshot_fn() + # Type pin: MagicMock compressors return truthy Mock objects — + # only a real int snapshot may arm the interrupted-turn rollback. + if isinstance(_snapshot_val, int) and not isinstance( + _snapshot_val, bool + ): + agent._turn_preflight_display_snapshot = _snapshot_val _defer_preflight = getattr( _compressor, "should_defer_preflight_to_real_usage", diff --git a/agent/turn_finalizer.py b/agent/turn_finalizer.py index 17b49479a88b..2126a9afdc26 100644 --- a/agent/turn_finalizer.py +++ b/agent/turn_finalizer.py @@ -205,18 +205,31 @@ def finalize_turn( # request. Roll that estimate back only when an interrupt wins the race # before any successful provider response. Compaction state remains owned # by the real-usage/post-compaction path, including its ``-1`` sentinel. + # Guard rules (test-double density on this path is high): + # - snapshot is type-pinned to a real int — MagicMock agents auto-create + # truthy Mock attributes that must never arm the rollback; + # - the received-response flag is pinned to ``is not True`` — its real + # domain is True/False, and only a literal True means a provider + # response completed; + # - the compressor method gets a getattr+callable guard — SimpleNamespace + # compressor doubles and plugin context engines lack it. _preflight_snapshot = getattr( agent, "_turn_preflight_display_snapshot", None ) if ( - interrupted - and _preflight_snapshot is not None - and not getattr(agent, "_turn_received_provider_response", False) + interrupted is True + and isinstance(_preflight_snapshot, int) + and not isinstance(_preflight_snapshot, bool) + and getattr(agent, "_turn_received_provider_response", False) is not True and getattr(agent, "context_compressor", None) is not None ): - agent.context_compressor.rollback_interrupted_preflight_display_tokens( - _preflight_snapshot + _rollback_fn = getattr( + agent.context_compressor, + "rollback_interrupted_preflight_display_tokens", + None, ) + if callable(_rollback_fn): + _rollback_fn(_preflight_snapshot) # Post-loop cleanup must never lose the response. Trajectory save, # resource teardown, and session persistence all touch fallible