From 427d85d7373312adb2496161a291015aefa1ee32 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 5 Sep 2026 11:26:25 +0100 Subject: [PATCH] fix(llm): commit the staged head hash unconditionally on a recorded response An unchained request with no system message staged None, and the record step skipped the commit, so the previous head hash survived a transcript that never received a head; a later chained request restoring that head would have omitted it. The staged value is now committed as-is, None included. Also pins each rejection predicate of is_usable_compression_point with its own test. --- application/llm/openai.py | 10 +++--- .../compression/test_compression_reuse.py | 31 +++++++++++++++++++ tests/llm/test_responses_chain_budget.py | 14 +++++++++ 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/application/llm/openai.py b/application/llm/openai.py index fb0cbd70..4581d5da 100644 --- a/application/llm/openai.py +++ b/application/llm/openai.py @@ -1423,10 +1423,12 @@ class OpenAILLM(BaseLLM): if rid: self._last_response_id = rid # The provider recorded this request, so the head it carried (or - # kept) is now the chain's head. - if self._pending_system_hash is not None: - self._chain_system_hash = self._pending_system_hash - self._pending_system_hash = None + # kept) is now the chain's head — including "no head at all", when + # an unchained request went out without a system message: a stale + # hash would let a later chained request omit a head this + # transcript never received. + self._chain_system_hash = self._pending_system_hash + self._pending_system_hash = None self._last_response_call_ids = self._function_call_ids(response) usage = getattr(response, "usage", None) if usage is not None: diff --git a/tests/api/answer/services/compression/test_compression_reuse.py b/tests/api/answer/services/compression/test_compression_reuse.py index fa11dab3..edb763c0 100644 --- a/tests/api/answer/services/compression/test_compression_reuse.py +++ b/tests/api/answer/services/compression/test_compression_reuse.py @@ -499,3 +499,34 @@ class TestUnusableSavedPoints: assert result.compression_performed assert svc.compress_and_save.call_args.kwargs["start_index"] == 2 + + +@pytest.mark.unit +class TestUsablePointPredicates: + """Each rejection predicate of ``is_usable_compression_point`` on its own.""" + + def _with_later_point(self, **point): + conv = _compressed_conversation() + conv["queries"].append({"prompt": "q4", "response": "r4"}) + conv["compression_metadata"]["compression_points"].append( + {"timestamp": "2026-09-04T09:00:00+00:00", "query_index": 3, **point} + ) + return conv + + def test_a_positive_count_does_not_rescue_a_blank_summary(self): + conv = self._with_later_point(compressed_summary=" ", compressed_token_count=12) + summary, recent = CompressionService(llm=None, model_id="m").get_compressed_context(conv) + assert summary == "S" + assert [q["prompt"] for q in recent] == ["q2", "q3", "q4"] + + def test_a_zero_count_does_not_rescue_a_non_blank_summary(self): + conv = self._with_later_point(compressed_summary="newer", compressed_token_count=0) + summary, recent = CompressionService(llm=None, model_id="m").get_compressed_context(conv) + assert summary == "S" + assert [q["prompt"] for q in recent] == ["q2", "q3", "q4"] + + def test_a_missing_count_with_a_summary_is_usable(self): + conv = self._with_later_point(compressed_summary="newer") + summary, recent = CompressionService(llm=None, model_id="m").get_compressed_context(conv) + assert summary == "newer" + assert [q["prompt"] for q in recent] == ["q4"] diff --git a/tests/llm/test_responses_chain_budget.py b/tests/llm/test_responses_chain_budget.py index 9314b17f..d0f3a4ac 100644 --- a/tests/llm/test_responses_chain_budget.py +++ b/tests/llm/test_responses_chain_budget.py @@ -402,3 +402,17 @@ def test_output_capped_response_still_records_chain_state(monkeypatch): assert llm._responses_gen("gpt-5.6", _messages(), tools=None) == "partial" assert llm._last_response_id == "resp_len" assert llm._chain_system_hash is not None + + +@pytest.mark.unit +def test_recorded_request_without_a_head_clears_the_committed_hash(monkeypatch): + llm = _make_llm(monkeypatch) + llm._build_responses_input(_messages("sys v1"), None) + _accepted(llm, "resp_1") + # An unchained fallback with no system message at all is recorded: the + # new transcript holds no head, so the old hash must not survive it. + llm._build_responses_input([{"role": "user", "content": "q"}], None) + _accepted(llm, "resp_2") + assert llm._chain_system_hash is None + chained, _ = llm._build_responses_input(_messages("sys v1"), "resp_2") + assert _roles(chained) == ["system", "user"]