fix(openai): preserve encrypted reasoning across turns on codex OAuth path

PR #2068 dropped every reasoning item from input[] on the OAuth/codex path
(store=false). That silently discards encrypted_content -- the out-of-band
channel that carries reasoning context across turns under store=false --
degrading multi-turn agent reasoning with no visible error. Reported by
@neteroster on PR #2068.

The 404 that #2068 worked around ("Item with id 'rs_...' not found") is
triggered by the rs_* id lookup under store=false, not by the reasoning item
itself -- so the correct fix is to strip the id, not delete the item.

Verified end-to-end against the live chatgpt.com codex backend (gpt-5.5) and
a real OpenClaw container (api: openai-responses):
  - bare rs_ id, no encrypted_content  -> 404
  - id stripped                        -> 200
  - encrypted_content + id stripped    -> 200, reasoning context preserved
  - reasoning items require a summary field (missing -> 400)
  - real OpenClaw multi-turn agent loop -> all /v1/responses 200, zero 404

Fix: keep the reasoning item, strip only the rs_* id (always, independent of
PreserveReferences), preserve encrypted_content/content/summary verbatim, and
backfill an empty summary when absent. Tool-call call_id pairing is untouched.

Also verified compaction_summary items (cmp_*, the other encrypted_content
carrier): they require encrypted_content (missing -> 400) and their id does not
404 when present (kept or stripped), so the existing generic path already
handles them safely -- no special-casing needed.

Adds regression tests for each verified reasoning contract.

Refs #1957, #2068
This commit is contained in:
Oganneson
2026-06-30 21:08:40 +08:00
parent dc1bc15457
commit 73de2ea7f0
2 changed files with 206 additions and 31 deletions
@@ -1157,11 +1157,41 @@ func filterCodexInputWithOptions(input []any, opts codexInputFilterOptions) []an
}
typ, _ := m["type"].(string)
// chatgpt.com codex backend (OAuth path) does not persist reasoning
// items because applyCodexOAuthTransform forces store=false. Any rs_*
// reference replayed in input is guaranteed to 404 upstream
// ("Item with id 'rs_...' not found"). Drop reasoning items entirely.
// chatgpt.com codex (OAuth path) runs with store=false (forced by
// applyCodexOAuthTransform). Replaying a reasoning item with its rs_*
// id but no encrypted_content 404s upstream ("Item with id 'rs_...'
// not found") — the 404 is triggered by the id lookup, not by the
// reasoning item itself. So strip the id (always, independent of
// PreserveReferences) yet keep the item: under store=false
// encrypted_content is the official channel for carrying reasoning
// context across turns, and dropping the whole item silently degrades
// multi-turn agent reasoning. Preserve encrypted_content/content/
// summary and every other field verbatim. Upstream additionally
// requires a summary field — a missing one is rejected with 400
// "Missing required parameter 'input[N].summary'" — so backfill an
// empty array when it is absent. Contracts verified end-to-end against
// chatgpt.com codex (gpt-5.5); see issue #1957.
// compaction_summary items (cmp_*) are the other encrypted_content
// carrier. Verified against the live backend: they require
// encrypted_content (a missing one is rejected with 400), and with it
// present the cmp_* id does not 404 whether kept or stripped. Being
// neither reasoning nor tool calls, they flow through the generic path
// below (id stripped when !PreserveReferences, encrypted_content
// preserved either way), which is safe and needs no special-casing.
if typ == "reasoning" {
newItem := make(map[string]any, len(m))
for key, value := range m {
if key == "id" {
// rs_* id replayed under store=false 404s; strip it.
continue
}
newItem[key] = value
}
if summary, ok := newItem["summary"]; !ok || summary == nil {
// Upstream requires a summary field; an empty array satisfies it.
newItem["summary"] = []any{}
}
filtered = append(filtered, newItem)
continue
}