Fix Codex OAuth utility responses

Preserve completed SSE output items when the final Codex response envelope omits them so non-streaming utility calls retain their generated text. Add focused regression coverage for the native output-item path.

Alessandro committed Aug 19, 2026 at 04:23 UTC f6dd6128d9ad4ff27d8d3beb346ce1e5386efcd6
3 files changed +53
plugins/_oauth/AGENTS.md
+1
@@ -47,6 +47,7 @@
47 - Codex Responses proxy requests must translate the legacy top-level `reasoning_effort` field to `reasoning.effort`; an explicit native `reasoning` field takes precedence.
48 - Codex Responses proxy defaults for reasoning effort, reasoning summary, and text verbosity come from the `codex` plugin config; explicit native request values take precedence.
49 - Codex request shaping tightens an already-advertised native `response` tool to a strict required `text` schema; it must not add tools omitted by the framework tool policy.
50 +- Non-streaming Codex proxy responses must retain completed SSE output items when the final `response.completed` envelope omits them.
51 - OAuth providers without upstream Responses support must set `a0_api_mode: chat`; native Responses providers rely on the default, since a local proxy route alone does not prove upstream support.
52
53 ## Work Guidance
plugins/_oauth/helpers/codex.py
+16
@@ -794,6 +794,7 @@ def collect_completed_response(response: requests.Response) -> dict[str, Any]:
794 latest_error: Any = None
795 text_pieces: list[str] = []
796 latest_usage: dict[str, Any] | None = None
797 + completed_items: dict[int, dict[str, Any]] = {}
798 for event in iter_sse_events(response):
799 data = event.get("data")
800 if not data:
@@ -808,6 +809,11 @@ def collect_completed_response(response: requests.Response) -> dict[str, Any]:
809 latest_error = parsed
810 continue
811 text_pieces.extend(extract_sse_text_deltas(parsed, event.get("event", "")))
812 + if (parsed.get("type") or event.get("event")) == "response.output_item.done":
813 + output_index = parsed.get("output_index")
814 + item = parsed.get("item")
815 + if isinstance(output_index, int) and isinstance(item, dict):
816 + completed_items[output_index] = item
817 usage = parsed.get("usage")
818 if isinstance(usage, dict):
819 latest_usage = usage
@@ -815,6 +821,16 @@ def collect_completed_response(response: requests.Response) -> dict[str, Any]:
821 if isinstance(candidate, dict):
822 latest_response = candidate
823
824 + if (
825 + latest_response is not None
826 + and completed_items
827 + and not latest_response.get("output")
828 + ):
829 + latest_response = dict(latest_response)
830 + latest_response["output"] = [
831 + completed_items[index] for index in sorted(completed_items)
832 + ]
833 +
834 if text_pieces:
835 text = "".join(text_pieces)
836 if latest_response is not None:
tests/test_oauth_codex.py
+36
@@ -521,6 +521,42 @@ def test_extract_sse_text_deltas_ignores_final_done_text():
521 )
522
523
524 +def test_collect_completed_response_restores_native_output_items():
525 + item = {
526 + "id": "msg_1",
527 + "type": "message",
528 + "status": "completed",
529 + "content": [
530 + {
531 + "type": "output_text",
532 + "annotations": [],
533 + "logprobs": [],
534 + "text": "Hello",
535 + }
536 + ],
537 + "role": "assistant",
538 + }
539 +
540 + class FakeResponse:
541 + encoding = "utf-8"
542 +
543 + def iter_content(self, chunk_size=8192, decode_unicode=True):
544 + del chunk_size, decode_unicode
545 + yield (
546 + 'data: {"type":"response.output_item.done","output_index":0,'
547 + f'"item":{json.dumps(item)}}}\n\n'
548 + ).encode()
549 + yield (
550 + b'data: {"type":"response.completed",'
551 + b'"response":{"id":"resp_1","output":[]}}\n\n'
552 + )
553 +
554 + assert codex.collect_completed_response(FakeResponse()) == {
555 + "id": "resp_1",
556 + "output": [item],
557 + }
558 +
559 +
560 def test_collect_completed_response_falls_back_to_text_deltas():
561 class FakeResponse:
562 encoding = "utf-8"