From b374f52063788311629c02dcf4c9f2d98a3c2d42 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 25 Mar 2026 18:22:21 -0700 Subject: [PATCH] fix(session): clear compressor summary and turn counter on /clear and /new (#3102) reset_session_state() was missing two fields added after it was written: - _user_turn_count: kept accumulating across sessions, affecting flush_min_turns guard behavior - context_compressor._previous_summary: old session's compression summary leaked into new session's iterative compression Cherry-picked from PR #2640 by dusterbloom. Closes #2635. --- run_agent.py | 5 ++ tests/test_session_reset_fix.py | 121 ++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 tests/test_session_reset_fix.py diff --git a/run_agent.py b/run_agent.py index af8200818..a0e3f2cec 100644 --- a/run_agent.py +++ b/run_agent.py @@ -1122,6 +1122,9 @@ class AIAgent: self.session_cost_status = "unknown" self.session_cost_source = "none" + # Turn counter (added after reset_session_state was first written — #2635) + self._user_turn_count = 0 + # Context compressor internal counters (if present) if hasattr(self, "context_compressor") and self.context_compressor: self.context_compressor.last_prompt_tokens = 0 @@ -1129,6 +1132,8 @@ class AIAgent: self.context_compressor.last_total_tokens = 0 self.context_compressor.compression_count = 0 self.context_compressor._context_probed = False + # Iterative summary from previous session must not bleed into new one (#2635) + self.context_compressor._previous_summary = None def _safe_print(self, *args, **kwargs): """Print that silently handles broken pipes / closed stdout. diff --git a/tests/test_session_reset_fix.py b/tests/test_session_reset_fix.py new file mode 100644 index 000000000..ee65ed90d --- /dev/null +++ b/tests/test_session_reset_fix.py @@ -0,0 +1,121 @@ +"""Tests for session reset completeness (fixes #2635). + +/clear and /new must not carry stale state into the next session. +Two fields were added after reset_session_state() was written and were +therefore never cleared: + - ContextCompressor._previous_summary + - AIAgent._user_turn_count +""" +import sys +import types +from pathlib import Path + +import pytest + +# Ensure repo root is importable +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +# Stub out optional heavy dependencies not installed in the test environment +sys.modules.setdefault("fire", types.SimpleNamespace(Fire=lambda *a, **k: None)) +sys.modules.setdefault("firecrawl", types.SimpleNamespace(Firecrawl=object)) +sys.modules.setdefault("fal_client", types.SimpleNamespace()) + +from run_agent import AIAgent +from agent.context_compressor import ContextCompressor + + +def _make_minimal_agent() -> AIAgent: + """Return an AIAgent constructed with the absolute minimum args. + + We pass dummy values that bypass network calls and filesystem access. + The object is never used to make API calls — only its attributes and + reset_session_state() are exercised. + """ + agent = AIAgent.__new__(AIAgent) # skip __init__ entirely + + # Seed the exact attributes that reset_session_state() writes + agent.session_total_tokens = 0 + agent.session_input_tokens = 0 + agent.session_output_tokens = 0 + agent.session_prompt_tokens = 0 + agent.session_completion_tokens = 0 + agent.session_cache_read_tokens = 0 + agent.session_cache_write_tokens = 0 + agent.session_reasoning_tokens = 0 + agent.session_api_calls = 0 + agent.session_estimated_cost_usd = 0.0 + agent.session_cost_status = "unknown" + agent.session_cost_source = "none" + + # The two fields under test + agent._user_turn_count = 0 + agent.context_compressor = None # will be set per-test as needed + + return agent + + +class TestResetSessionState: + """reset_session_state() must clear ALL session-scoped state.""" + + def test_previous_summary_cleared_on_reset(self): + """Compression summary from old session must not leak into new session.""" + agent = _make_minimal_agent() + compressor = ContextCompressor.__new__(ContextCompressor) + compressor._previous_summary = "Old session summary about unrelated topic" + # Seed counter attributes that reset_session_state touches + compressor.last_prompt_tokens = 100 + compressor.last_completion_tokens = 50 + compressor.last_total_tokens = 150 + compressor.compression_count = 3 + compressor._context_probed = True + + agent.context_compressor = compressor + + agent.reset_session_state() + + assert compressor._previous_summary is None, ( + "_previous_summary must be None after reset; got: " + f"{compressor._previous_summary!r}" + ) + + def test_user_turn_count_cleared_on_reset(self): + """Turn counter must reset to 0 on new session.""" + agent = _make_minimal_agent() + agent._user_turn_count = 7 # simulates turns accumulated in previous session + agent.context_compressor = None + + agent.reset_session_state() + + assert agent._user_turn_count == 0, ( + f"_user_turn_count must be 0 after reset; got: {agent._user_turn_count}" + ) + + def test_both_fields_cleared_together(self): + """Both stale fields are cleared in a single reset_session_state() call.""" + agent = _make_minimal_agent() + agent._user_turn_count = 3 + + compressor = ContextCompressor.__new__(ContextCompressor) + compressor._previous_summary = "Stale summary" + compressor.last_prompt_tokens = 0 + compressor.last_completion_tokens = 0 + compressor.last_total_tokens = 0 + compressor.compression_count = 0 + compressor._context_probed = False + agent.context_compressor = compressor + + agent.reset_session_state() + + assert agent._user_turn_count == 0 + assert compressor._previous_summary is None + + def test_reset_without_compressor_does_not_raise(self): + """reset_session_state() must not raise when context_compressor is None.""" + agent = _make_minimal_agent() + agent._user_turn_count = 2 + agent.context_compressor = None + + # Must not raise + agent.reset_session_state() + + assert agent._user_turn_count == 0