fix(openai-responses): recover streamed invalid_encrypted_content via drain-level retry (#95441)#95587
Conversation
|
Codex review: needs real behavior proof before merge. Reviewed July 13, 2026, 10:49 PM ET / July 14, 2026, 02:49 UTC. Summary PR surface: Source +151, Tests +322. Total +473 across 2 files. Reproducibility: yes. at source level: create-only recovery cannot catch response.failed or type:error exceptions raised while draining the async stream. Real provider field reports support the failure class, but this review did not establish a live current-main reproduction itself. Review metrics: none identified. Root-cause cluster Members:
Proposal only: this assessment does not dispatch repair, suppress jobs, mutate sibling items, close, or merge anything. Merge readiness Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch. Rank-up moves:
Proof guidance:
Risk before merge
Maintainer options:
Next step before merge
Maintainer decision needed
Security Review detailsBest possible solution: Rebase onto current main, preserve the bounded no-content-emitted retry as defense in depth only if a real provider or deployment run at the rebased head proves one recovery with no duplicate assistant lifecycle, and coordinate it explicitly with the Copilot provider-boundary strip while leaving polluted-history repair in the canonical issue. Do we have a high-confidence way to reproduce the issue? Yes at source level: create-only recovery cannot catch response.failed or type:error exceptions raised while draining the async stream. Real provider field reports support the failure class, but this review did not establish a live current-main reproduction itself. Is this the best way to solve the issue? No as a standalone root-cause solution; it is a well-bounded recovery layer, while the narrower provider-boundary prevention in #95493 and a separate polluted-history decision form the more durable end state. AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against 9b823c610816. Label changesLabel justifications:
Evidence reviewedPR surface: Source +151, Tests +322. Total +473 across 2 files. View PR surface stats
What I checked:
Likely related people:
What the crustacean ranks mean
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics. How this review workflow works
Review history (1 earlier review cycle)
|
NianJiuZst
left a comment
There was a problem hiding this comment.
Review: drain-level encrypted-content retry
Walked the diff + full current openai-transport-stream.ts at the PR head (f3f9a49) vs main + the 11 new gates, plus the linked #95441.
What it does (one paragraph)
The existing createResponsesStreamWithEncryptedContentRetry only retries the pre-stream client.responses.create() failure. The poison invalid_encrypted_content / thinking_signature_invalid from #95441 actually surfaces mid-stream as a response.failed SSE event, which processResponsesStream was throwing to the outer transport catch with no retry. This widens the recovery to wrap create + drain (runResponsesDrainWithEncryptedRetry), normalizes the streamed type: "error" shape so it surfaces the structured code (matching what response.failed already does), widens the matcher to the bare-prose pre-stream 400, and broadens the sanitizer to also drop thinkingSignature / textSignature (issue fix #3). Single start is moved before the create so the retry stays invisible to the consumer.
LOC
+603 / -20 over 2 files (src/agents/openai-transport-stream.ts +199/-20, src/agents/openai-transport-stream.test.ts +404/-0). Two files only — touches the right seam.
Findings
No blocking findings. The change is correct, well-scoped, well-tested. The 11 gates cover the real exported drain path (not just matchers), and the test ordering mirrors the actual call site (start pushed before the helper, exactly one start asserted across retry).
A few small things worth a follow-up note in the PR body or a follow-up commit, none of which should block landing:
-
createResponsesStreamWithEncryptedContentRetryis now dead in the production path.createOpenAIResponsesTransportStreamFnwas rewired to callrunResponsesDrainWithEncryptedRetryinstead, which subsumes both the pre-stream and the mid-stream cases via a single catch. The pre-stream helper is still exported viatestingbut no production caller remains. Two clean options:- delete it (cleanest; the drain helper now owns the strip+retry logic)
- keep it and have the drain helper delegate the pre-stream path to it (single source of truth for the strip+retry policy)
Either is fine; what's not great is leaving it as silently-unused exported code. If you keep it, a one-line comment that it is retained for the
testingsurface / future internal reuse would be enough. -
isInvalidEncryptedContentErrorcarries adepthparameter on the public surface. The exported function now defaultsdepth = 0purely for the recursive walk. Minor style smell — the public matcher shouldn't carry recursion plumbing. A private_isInvalidEncryptedContentError(error, depth)with adepth = 0-defaulting public wrapper reads cleaner. Trivial. -
Bare-prose matcher is a heuristic, not a contract. The new
lowerMessage.includes("encrypted content") && (...verified | ...decrypted | ...parsed)branch is a reasonable defensive catch for the documented #95441 shape, and the Gate 10 negative case ("content moderation failed") locks the non-match. Just worth knowing that any future provider message that happens to contain those three substrings together will trigger an extracreate()call. If you ever want to tighten, the right move is to ship a regex tied to a known provider string, but for a mitigation that ships today this is fine. -
ResponsesStreamFailureErroris the right level of abstraction. Carriescode+responseIdso the drain-level retry detector doesn't have to re-parse message strings. Both throw sites (type: "error",response.failed) now use it. Good. -
Sibling check is explicit and OK. The PR body says "Responses-family only; Anthropic/Google signed-thinking replay is untouched." That is the right scope for an immediate mitigation — a single-seam recovery fix touching one transport is much safer than a broad provider rewrite. If Anthropic/Google hit the same shape, a follow-up per-provider PR is the right vehicle, not bundling here.
Best-fix verdict
Acceptable mitigation, scoped as advertised. Not the best possible fix for the underlying bug — the root cause is the persistence layer still writing thinkingSignature / textSignature / encrypted_content into session .jsonl (issue fix #1) and the absence of a .jsonl repair migration (fix #4). You call this out clearly in the PR body, and the scope is appropriate: a transport-level recovery that stops the user-facing LLM request failed while a larger persistence-layer fix is designed separately.
Alternatives considered (and why I think your call is the right one for this PR):
- Root-cause fix in this same PR — would need to touch the persistence layer and every embedded channel/direct writer. Too broad for a maintainer-reviewable change, and your PR would explode in scope. Right to defer.
- Drop the matching widening (stick to flat
event.codeonly) — would leave Gate 8's production path (nestedtype: "error") uncovered, which is the actual #95441 surface. Wrong. - Strip
thinkingSignature/textSignatureat persistence time only (no transport-time strip) — wouldn't recover the already-poisoned sessions that are causing the production failure today. The transport-time strip is what makes the recovery actually recover.
Code read
src/agents/openai-transport-stream.ts(full, 4606 lines at PR head; compared against main 4427)src/agents/openai-transport-stream.test.ts(newdescribeblock lines 4627–5036; compared against main 11294-line file at line 4534 for the pre-existingstripResponsesRequestEncryptedContenttest)- Issue #95441 (full body, labels include
P1,clawsweeper:no-new-fix-pr,clawsweeper:needs-maintainer-review,clawsweeper:source-repro,impact:session-state,impact:message-loss,impact:auth-provider,issue-rating: 🦞 diamond lobster) - PR diff: 732 lines
Remaining uncertainty
- Bare-prose false-positive rate is unquantified beyond the one Gate 10 negative case. If you have production-log samples that would let you tighten the regex without losing the real matches, that would be worth a follow-up.
- Behavior under concurrent
options.signal.abort()mid-drain is not directly tested. Pre-existing behavior is preserved (AbortError is notisInvalidEncryptedContentError, so the drain helper rethrows without retry), but a regression test for "abort during the failed attempt + retry" would lock the contract. - Whether
output.responseId = undefined; output.stopReason = "stop"reset before the retry is sufficient to prevent any double-tracing in consumers that readoutputpost-hoc. ReadingprocessResponsesStreamend-to-end, the only writes to those fields are gated on the final event types, and the retry's success path overwrites them — but a regression test that assertsoutput.responseIdmatches the retry response (Gate 5) is the only thing locking that today.
Provenance
Provenance: N/A — this is not a regression fix; the encrypted-content bug is present on current main. The PR is a recovery, not a revert.
Leaving as a COMMENT review rather than approve / request-changes because of the small cleanup items above; happy to flip to approve once you've decided on the pre-stream helper disposition. Thanks for the transparent scope/honesty note in the PR body — it makes the review much easier.
…nt errors The encrypted reasoning replay retry only wrapped client.responses.create() (pre-stream). In practice invalid_encrypted_content / thinking_signature_invalid arrive mid-stream as a response.failed SSE event thrown inside processResponsesStream, which bubbled to the outer transport catch with no retry and surfaced as a hard 'LLM request failed'. Widen the retry to cover create + drain via runResponsesDrainWithEncryptedRetry: on a streamed encrypted-content failure with no committed assistant content, it strips the encrypted payload and re-issues once into the same already-started output/stream (single start/done, no duplicate partial). Also extend isInvalidEncryptedContentError to match nested SDK error shapes (error/cause/response.body) and carry the structured failure code on response.failed throws. Scope is Responses-family only; Anthropic/Google signed-thinking replay is untouched. Adds 7 gate tests covering retry-once, no-retry-after-content, no-op-strip, retry-cap, responseId boundary, single lifecycle, and the nested matcher. Refs openclaw#95441
f3f9a49 to
becdaee
Compare
|
@NianJiuZst thanks for the review — addressed the pre-stream helper disposition: deleted The Also added a redacted live-path proof to the PR body (runs the real exported drain helper, shows fail→strip→single-retry→recover with one @clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. |
|
Field report from an affected Observed surface:
Local mitigation proof:
Caveat: this is not a validation run of this PR branch/head. It is live field evidence that removing stale private replay material recovers an affected channel/direct session. It also suggests the complete upstream fix likely needs both transport/send-time recovery and storage-time prevention or repair for already-polluted histories. |
|
@fanyangCS thanks for the PR. ClawSweeper is still waiting on real behavior proof before this can move forward. Useful proof can be a screenshot, short video, terminal output, copied live output, linked artifact, or redacted logs that show the changed behavior after the fix. Please redact private tokens, phone numbers, private endpoints, customer data, and anything else sensitive. Once proof is added to the PR body or a comment, ClawSweeper or a maintainer can re-check it. |
Real-behavior proof (test-run output at PR head
|
Complementary live evidence: why create-only recovery missed the real failureI reproduced the canonical #95441 failure on OpenClaw 2026.7.1 with The installed transport already had a create-time encrypted-content matcher/retry. Nevertheless, after a full gateway restart the resumed turn still produced: The embedded runner then retried the same poisoned request three times and surfaced Code-path inspection confirmed the gap described by this PR:
For immediate local recovery I installed the proactive final Copilot provider-boundary strip from #95493. After a full process restart, the same Telegram session ID resumed without transcript cleanup or session rotation; three observed requests returned HTTP 200, with zero new HTTP 400 encrypted-content, This live run directly confirms the create-only blind spot that #95587 addresses. Because the proactive #95493 fix prevents the provider rejection before it can reach the drain path, this is not a live execution of PR head
Recommendation: land #95493 as root-cause prevention and keep the narrow, no-content-emitted drain retry from #95587 as defense-in-depth for legacy/alternate paths and future provider error shapes. |
Updated recommendation: prefer the combined fix in #107266Following the additional live investigation I posted above, I believe #107266 is now the closest match to the complete fix demonstrated on my affected OpenClaw installation. The failure needed protection at both layers:
That is the architecture #107266 now implements. It incorporates the valuable drain-level recovery developed here while also addressing the cold-history/provider-boundary side that #95587 intentionally leaves out. For that reason, I support #107266 as the preferred successor for resolving #95441, subject to maintainer review and live proof. I can provide credentialed, redacted evidence from the affected |
Production context: - Vision/main was failing long-lived github-copilot/gpt-5.5 sessions with generic `LLM request failed` after replaying stored Responses reasoning items containing `encrypted_content`. - The raw upstream failure observed in OpenClaw logs was: `OpenAI API error (400): 400 The encrypted content ... could not be verified. Reason: Encrypted content could not be decrypted or parsed.` - Session reset temporarily worked because it removed poisoned replay state. Root cause: - Copilot encrypted reasoning content is bound to an ephemeral Copilot connection/access token, not just the stable OpenClaw session/auth/model metadata used by the core Responses replay provenance check. - Before this commit, `sanitizeCopilotReplayResponseIds` only dropped unsafe connection-bound reasoning IDs. It kept valid/idless reasoning items with `encrypted_content`, so stale token-bound ciphertext could still be sent to Copilot on later turns. Fix shape: - At the GitHub Copilot provider payload boundary, strip `encrypted_content` from every kept `type: "reasoning"` replay item. - Preserve safe reasoning IDs and summaries so replay remains summary-only instead of deleting useful non-opaque context. - Continue dropping unsafe reasoning IDs exactly as before. - Add a narrow transport safety net so Copilot's prose 400 shape is classified as recoverable by the existing one-shot encrypted-content strip/retry path. Merge/update guidance: - Upstream analogue to compare/drop against: openclaw#95493 (`fix(github-copilot): strip encrypted_content from reasoning replay items`). If that PR or equivalent lands upstream, prefer upstream's Copilot-boundary strip and drop the matching part of this commit during rebase. - The transport classifier is an extra safety net, not the primary fix. It can be dropped if upstream has broader recovery equivalent to openclaw#95587 or another matcher covering Copilot's prose error: `encrypted content` + `could not be verified` + `decrypted or parsed`. - Keep provider scope narrow: do not replace this with a global removal of all Responses reasoning unless upstream intentionally changes replay policy. Verification already run before committing: - pnpm test extensions/github-copilot/connection-bound-ids.test.ts - pnpm exec oxfmt --check --threads=1 extensions/github-copilot/connection-bound-ids.ts extensions/github-copilot/connection-bound-ids.test.ts src/agents/openai-transport-stream.ts src/agents/openai-transport-stream.test.ts - pnpm exec oxlint extensions/github-copilot/connection-bound-ids.ts extensions/github-copilot/connection-bound-ids.test.ts src/agents/openai-transport-stream.ts src/agents/openai-transport-stream.test.ts - git diff --check - pnpm build Deployment note: - Deployed to Vision/main on 2026-06-30 by building /home/bryan/openclaw and restarting `openclaw-gateway.service` (wrapper: /home/bryan/.local/bin/openclaw-main-dev, port 18789). Health returned `{ "ok": true, "status": "live" }`. (cherry picked from commit fa8562c3214252031dc075f1cd5563b33e4f1306) (cherry picked from commit 1bb7c0d5ca1bbd13902dcc72d3897c391bf94992)
|
@fanyangCS thanks for the PR. ClawSweeper is still waiting on real behavior proof before this can move forward. Useful proof can be a screenshot, short video, terminal output, copied live output, linked artifact, or redacted logs that show the changed behavior after the fix. Please redact private tokens, phone numbers, private endpoints, customer data, and anything else sensitive. Once proof is added to the PR body or a comment, ClawSweeper or a maintainer can re-check it. |
Refs #95441
Summary
The encrypted-content replay retry only wrapped
client.responses.create()(the pre-stream call). In practice,invalid_encrypted_content/thinking_signature_invalidcan also arrive mid-stream as aresponse.failed(ortype: "error") SSE event thrown insideprocessResponsesStream. That path bubbled to the outer transportcatchwith no retry and surfaced to the user as a hardLLM request failedwith no deliverable assistant reply.This widens the encrypted-content recovery to cover create + drain, and broadens detection/sanitization so the real-world failure shapes from #95441 are actually caught.
What changed
runResponsesDrainWithEncryptedRetry): on a streamed encrypted-content failure with no committed assistant content, strip the encrypted payload and re-issue once into the same already-started output/stream. Singlestart/done, no duplicate partial. If any assistant content was already emitted, it rethrows (never double-emits).normalizeResponsesErrorEvent): the streamedtype: "error"branch previously only read flatevent.code. A provider sending the code nested ({ type: "error", error: { code: "invalid_encrypted_content" } }) lost the structured code, so the retry detector never fired. It now reads both flat and nestedcode/message(mirroringresponse.failed).isInvalidEncryptedContentError): the provider's pre-stream400can arrive as human-readable prose with no structured code (e.g. "The encrypted content for item … could not be verified."). The matcher now recognizes that shape too.stripEncryptedContentFields): the sanitized retry now stripsthinkingSignature/textSignaturein addition to nestedencrypted_content(this is github-copilot/gpt-5.5 still persists/replays thinkingSignature encrypted_content after #84367/#90682/#92941, causing channel/direct LLM request failed #95441's suggested fix WA business, groups & office hours #3 — drop all replay-only provider-private reasoning material, not justencrypted_content).Scope is Responses-family only; Anthropic/Google signed-thinking replay is untouched.
Tests
Adds gate tests driving the real exported drain path with mocked SSE sequences (not just matcher unit tests):
startresponseIdboundary handlingtype: "error"→ drain → retry → sanitized request → success400prose matching (and non-matching of unrelated prose)thinkingSignature/textSignature, not justencrypted_contentVerified locally on top of current
main:vitest run src/agents/openai-transport-stream.test.ts→ 566 passed, 0 regressionsoxlint src/agents/openai-transport-stream.ts src/agents/openai-transport-stream.test.ts→ 0 warnings, 0 errorsThis change has also been running on a live deployment (
github-copilot/gpt-5.5, Discord/Telegram/Feishu) and has been stable, recovering turns that previously hard-failed.Scope / honesty note
This is a recovery / mitigation fix, not a full root-cause fix for #95441:
LLM request failed..jsonlin the first place, and does not repair already-polluted histories. So affected turns still take one failed attempt before the sanitized retry succeeds..jsonl(Images not passed to Claude CLI - only path reference in text #4) — are intentionally out of scope here and would be a larger, separate change touching the persistence layer and all embedded channel/direct paths.Note for maintainers
Issue #95441 carries the
clawsweeper:no-new-fix-pr/clawsweeper:needs-maintainer-reviewlabels. This PR is opened at the explicit request of the repository owner (the user running this agent), as a small, test-covered, deployment-validated mitigation for the streamed-failure gap. Happy to close/hold if maintainers would rather land the root-cause approach instead, or rework per review.Update (review response + live-path proof)
Addressed @NianJiuZst's review (thanks for the thorough read). The pre-stream
createResponsesStreamWithEncryptedContentRetryis now deleted —runResponsesDrainWithEncryptedRetrysubsumes both the pre-streamcreate()failure and the mid-streamresponse.failed/type:"error"failure through a single catch, so there is no longer any silently-unused exported helper. Its now-redundant unit test (and the onlyimport OpenAIthat test needed) were removed; the create-failure path stays covered by the drain gates (they reject the firstcreate()and assert the single sanitized retry). Net diff is smaller; still 2 files.Re-verified after the cleanup: targeted drain gates 22 passed,
oxlint0 warnings / 0 errors. (The 3 red CI checks are unrelated to this diff —Real behavior proofis the policy gate addressed below;checks-node-core-fast/checks-node-core-toolingfail onresolve-openclaw-ref/bench-gateway-restart/test-projects, i.e. git-ls-remote and spawn/ENOENT CI-env flakes on files this PR never touches. Theagents-core/agents-supportshards that own this file pass.)Real behavior proof. A standalone harness drives the real exported
runResponsesDrainWithEncryptedRetry(not a reimplementation) with a mocked Responses client: the first drain emitsresponse.failed: invalid_encrypted_contentmid-stream; the helper detects it, strips the replay-private fields, and re-drains once. Redacted, deterministic terminal output:This corroborates the deployment experience noted above: the streamed encrypted-content failure retries exactly once, strips
encrypted_content/thinkingSignature/textSignature, emits a singlestart(no duplicate start/partial/done), and recovers the assistant reply instead of surfacingLLM request failed.Reproducible harness (run with
tsxagainst this PR head)