From 5837c300df6c3104480b700a95f0c5c3232d2389 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 5 Sep 2026 11:07:07 +0100 Subject: [PATCH] fix(llm): record Responses chain state only for completed responses The non-streaming path recorded the response id, reasoning items and the staged system-head hash before checking the terminal status, so a response that came back failed could become the id the next call chains onto and commit a head the stored transcript never received. Chain state is now recorded after the status checks; a response cut off by max_output_tokens still records it, since its input was accepted. --- application/llm/openai.py | 11 +++++- tests/llm/test_responses_chain_budget.py | 48 ++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/application/llm/openai.py b/application/llm/openai.py index d0aa7ab9..fb0cbd70 100644 --- a/application/llm/openai.py +++ b/application/llm/openai.py @@ -1551,11 +1551,13 @@ class OpenAILLM(BaseLLM): "OpenAI Responses request completed id=%s", getattr(response, "id", None), ) - self._record_responses_metadata(response) content, tool_calls, reasoning_items = self._parse_responses_output( response ) - self._last_reasoning_items = reasoning_items + # Chain state (response id, head hash, reasoning items) is recorded + # only for a response the provider actually completed — a failed one + # must not become the id the next call chains onto, nor commit a head + # the stored transcript never received. details = getattr(response, "incomplete_details", None) incomplete_reason = getattr(details, "reason", None) if getattr(response, "status", None) == "incomplete": @@ -1564,6 +1566,9 @@ class OpenAILLM(BaseLLM): self._responses_status_error(response) or "Responses API incomplete: unknown reason" ) + # Cut off by the output cap: the input was accepted and stored. + self._record_responses_metadata(response) + self._last_reasoning_items = reasoning_items self._last_finish_reason = "length" if tools: return _RespChoice( @@ -1574,6 +1579,8 @@ class OpenAILLM(BaseLLM): status_error = self._responses_status_error(response) if status_error: raise RuntimeError(status_error) + self._record_responses_metadata(response) + self._last_reasoning_items = reasoning_items if tools: self._remember_reasoning(tool_calls, reasoning_items) message = _RespMessage( diff --git a/tests/llm/test_responses_chain_budget.py b/tests/llm/test_responses_chain_budget.py index 3f1a3e7e..9314b17f 100644 --- a/tests/llm/test_responses_chain_budget.py +++ b/tests/llm/test_responses_chain_budget.py @@ -354,3 +354,51 @@ def test_failed_chained_request_resends_changed_head_on_retry(monkeypatch): _accepted(llm, "resp_2") after, _ = llm._build_responses_input(_messages("sys v2"), "resp_2") assert _roles(after) == ["user"] + + +# ── a failed non-streaming response must not become chain state ───────────── + + +@pytest.mark.unit +def test_failed_response_records_no_chain_state(monkeypatch): + llm = _make_llm(monkeypatch) + # An accepted turn with head "sys v1". + llm._build_responses_input(_messages("sys v1"), None) + _accepted(llm, "resp_ok") + committed = llm._chain_system_hash + + failed = types.SimpleNamespace( + id="resp_bad", status="failed", error=types.SimpleNamespace(message="boom"), + incomplete_details=None, output=[], usage=None, + ) + llm.client.responses.create = MagicMock(return_value=failed) + with pytest.raises(RuntimeError): + llm._responses_gen( + "gpt-5.6", _messages("sys v2"), tools=None, previous_response_id="resp_ok" + ) + + # Neither the failed id nor the head it carried became chain state + # (the in-turn id is cleared before every request; the retry chains via + # the caller's previous_response_id)... + assert llm._last_response_id != "resp_bad" + assert llm._chain_system_hash == committed + # ...so the retry chains onto resp_ok and re-sends the changed head. + retry, prev = llm._build_responses_input(_messages("sys v2"), "resp_ok") + assert prev == "resp_ok" + assert _roles(retry) == ["system", "user"] + + +@pytest.mark.unit +def test_output_capped_response_still_records_chain_state(monkeypatch): + llm = _make_llm(monkeypatch) + capped = types.SimpleNamespace( + id="resp_len", status="incomplete", + incomplete_details=types.SimpleNamespace(reason="max_output_tokens"), + output=[types.SimpleNamespace(type="message", content=[ + types.SimpleNamespace(type="output_text", text="partial")])], + usage=None, error=None, + ) + llm.client.responses.create = MagicMock(return_value=capped) + 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