Skip to content

fix(openai-responses): recover streamed invalid_encrypted_content via drain-level retry (#95441)#95587

Open
fanyangCS wants to merge 1 commit into
openclaw:mainfrom
fanyangCS:pr/responses-drain-encrypted-retry
Open

fix(openai-responses): recover streamed invalid_encrypted_content via drain-level retry (#95441)#95587
fanyangCS wants to merge 1 commit into
openclaw:mainfrom
fanyangCS:pr/responses-drain-encrypted-retry

Conversation

@fanyangCS

@fanyangCS fanyangCS commented Jun 21, 2026

Copy link
Copy Markdown

Agent-generated PR. Authored by Fan's OpenClaw assistant (argus_bot), independently cross-reviewed by a second agent (fan_bot), and verified on a live deployment before submission. Opened on the user's behalf. Maintainers: see the no-new-fix-pr note at the bottom — this is filed at the user's explicit request and is intentionally scoped as a recovery fix, not a full root-cause fix.

Refs #95441

Summary

The encrypted-content replay retry only wrapped client.responses.create() (the pre-stream call). In practice, invalid_encrypted_content / thinking_signature_invalid can also arrive mid-stream as a response.failed (or type: "error") SSE event thrown inside processResponsesStream. That path bubbled to the outer transport catch with no retry and surfaced to the user as a hard LLM request failed with 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

  1. Drain-level retry (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. Single start/done, no duplicate partial. If any assistant content was already emitted, it rethrows (never double-emits).
  2. Nested error-code extraction (normalizeResponsesErrorEvent): the streamed type: "error" branch previously only read flat event.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 nested code/message (mirroring response.failed).
  3. Bare-prose 400 matching (isInvalidEncryptedContentError): the provider's pre-stream 400 can 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.
  4. Broader sanitization (stripEncryptedContentFields): the sanitized retry now strips thinkingSignature / textSignature in addition to nested encrypted_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 just encrypted_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):

  • retry-once on empty-content failure + single start
  • no-retry-after-content (anti double-emit)
  • no-op when nothing to strip
  • retry cap (at most once)
  • responseId boundary handling
  • single assistant lifecycle
  • nested type: "error" → drain → retry → sanitized request → success
  • flat + nested error-code normalization
  • bare provider 400 prose matching (and non-matching of unrelated prose)
  • strips thinkingSignature / textSignature, not just encrypted_content

Verified locally on top of current main:

  • vitest run src/agents/openai-transport-stream.test.ts566 passed, 0 regressions
  • targeted drain gates → 22 passed
  • oxlint src/agents/openai-transport-stream.ts src/agents/openai-transport-stream.test.ts → 0 warnings, 0 errors

This 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:

  • It recovers a failing turn (strip + retry) so the symptom no longer surfaces as LLM request failed.
  • It does not stop poisoned replay material from being persisted to session .jsonl in the first place, and does not repair already-polluted histories. So affected turns still take one failed attempt before the sanitized retry succeeds.
  • The deeper fixes from the issue — persistence-time stripping (fix: add @lid format support and allowFrom wildcard handling #1) and a session-repair migration for existing .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-review labels. 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 createResponsesStreamWithEncryptedContentRetry is now deletedrunResponsesDrainWithEncryptedRetry subsumes both the pre-stream create() failure and the mid-stream response.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 only import OpenAI that test needed) were removed; the create-failure path stays covered by the drain gates (they reject the first create() and assert the single sanitized retry). Net diff is smaller; still 2 files.

Re-verified after the cleanup: targeted drain gates 22 passed, oxlint 0 warnings / 0 errors. (The 3 red CI checks are unrelated to this diff — Real behavior proof is the policy gate addressed below; checks-node-core-fast / checks-node-core-tooling fail on resolve-openclaw-ref / bench-gateway-restart / test-projects, i.e. git-ls-remote and spawn/ENOENT CI-env flakes on files this PR never touches. The agents-core / agents-support shards 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 emits response.failed: invalid_encrypted_content mid-stream; the helper detects it, strips the replay-private fields, and re-drains once. Redacted, deterministic terminal output:

=== #95441 streamed encrypted-content retry: LIVE PATH PROOF ===
create() calls                : 2  (expect 2: fail once, retry once)
retry warn emitted            : true
  -> [responses] retrying drain without encrypted reasoning content provider=REDACTED-provider api=openai-responses model=REDACTED-model responseId=resp_failed_1
1st request had encrypted_content    : true
1st request had thinkingSignature    : true
retry stripped encrypted_content     : true
retry stripped thinkingSignature     : true
retry serialized has NO encrypted_content : true
retry serialized has NO thinkingSignature : true
retry serialized has NO textSignature     : true
'start' events emitted        : 1  (expect 1: no duplicate start)
final output.responseId       : resp_ok_2  (success-path id from the recovered response)
recovered (no thrown error)   : true
=== PASS ===

This corroborates the deployment experience noted above: the streamed encrypted-content failure retries exactly once, strips encrypted_content / thinkingSignature / textSignature, emits a single start (no duplicate start/partial/done), and recovers the assistant reply instead of surfacing LLM request failed.

Reproducible harness (run with tsx against this PR head)
// tmp/proof-95441.mts — node_modules/.bin/tsx --tsconfig tsconfig.json tmp/proof-95441.mts
import { testing } from "../src/agents/openai-transport-stream.ts";

function streamOf(events: unknown[]): AsyncIterable<unknown> {
  return { async *[Symbol.asyncIterator]() { for (const e of events) yield e; } };
}

const poisonedRequest = {
  model: "REDACTED-model", stream: true,
  input: [
    { type: "reasoning", id: "rs_prior", encrypted_content: "REDACTED", thinkingSignature: "REDACTED", summary: [] },
    { type: "message", id: "msg_prior", role: "assistant", textSignature: "REDACTED",
      content: [{ type: "output_text", text: "prior answer" }] },
  ],
};

let createCalls = 0; const sentRequests: unknown[] = [];
const client = { responses: { create(req: unknown) {
  createCalls++; sentRequests.push(req);
  if (createCalls === 1) return Promise.resolve(streamOf([
    { type: "response.failed", response: { id: "resp_failed_1",
      error: { code: "invalid_encrypted_content", message: "encrypted content could not be verified" } } },
  ]));
  return Promise.resolve(streamOf([
    { type: "response.created", response: { id: "resp_ok_2" } },
    { type: "response.output_text.delta", delta: "Recovered reply." },
    { type: "response.completed", response: { id: "resp_ok_2", status: "completed", output: [], usage: {} } },
  ]));
} } };

const startEvents: unknown[] = [];
const output: Record<string, unknown> = { content: [], responseId: undefined, stopReason: undefined };
const stream = { push(ev: { type: string }) { if (ev.type === "start") startEvents.push(ev); } };
const warnLines: string[] = []; const origWarn = console.warn;
console.warn = (...a: unknown[]) => warnLines.push(a.join(" "));
const model = { id: "REDACTED-model", api: "openai-responses", provider: "REDACTED-provider",
  baseUrl: "https://REDACTED", reasoning: true, input: ["text"],
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 200000, maxTokens: 8192 };

stream.push({ type: "start", partial: output });
await testing.runResponsesDrainWithEncryptedRetry({
  client: client as never, request: poisonedRequest as never, requestOptions: undefined,
  model: model as never, output: output as never, stream: stream as never, processOptions: undefined as never });
console.warn = origWarn;

const retry = JSON.stringify(sentRequests[1]);
console.log("create() calls:", createCalls);
console.log("retry warn:", warnLines.some((l) => l.includes("retrying drain")));
console.log("retry has NO encrypted_content:", !retry.includes("encrypted_content"));
console.log("retry has NO thinkingSignature:", !retry.includes("thinkingSignature"));
console.log("retry has NO textSignature:", !retry.includes("textSignature"));
console.log("start events:", startEvents.length);

@clawsweeper

clawsweeper Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Codex review: needs real behavior proof before merge. Reviewed July 13, 2026, 10:49 PM ET / July 14, 2026, 02:49 UTC.

Summary
The PR replaces create-only encrypted-content recovery with one bounded create-plus-stream-drain retry, broadens streamed error normalization and replay-field sanitization, and adds focused transport regression tests.

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
Relationship: fixed_by_candidate
Canonical: #95441
Summary: This PR is a defense-in-depth candidate for the canonical encrypted reasoning replay failure; the sibling Copilot PR targets prevention at the provider boundary.

Members:

Proposal only: this assessment does not dispatch repair, suppress jobs, mutate sibling items, close, or merge anything.

Merge readiness
Overall: 🧂 unranked krab
Proof: 🧂 unranked krab
Patch quality: 🦞 diamond lobster
Result: blocked until real behavior proof from a real setup is added.

Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch.

Rank-up moves:

  • Rebase the PR onto current main and resolve the dirty merge state.
  • [P1] Add redacted real provider or deployment proof from the rebased head showing one successful retry and no duplicate assistant lifecycle.
  • [P1] Obtain an explicit maintainer decision on landing this alongside the Copilot provider-boundary fix.

Proof guidance:

  • [P1] Needs real behavior proof before merge: The new terminal output exercises production helper code with a mocked client and synthetic SSE events, while the live field reports explicitly did not run this PR head; a rebased-head real provider or deployment transcript is still required, with private identifiers and credentials redacted. After adding proof, update the PR body; ClawSweeper should re-review automatically. If it does not, the PR author or someone with repository write access can comment @clawsweeper re-review.

Risk before merge

  • [P1] The supplied branch-head evidence remains synthetic: it does not show an actual provider-generated streamed invalid_encrypted_content failure recovering on this PR without duplicate output.
  • [P1] The retry intentionally discards provider-private reasoning and signature state after rejection; maintainers must accept the possible reasoning-continuity reduction and generic Responses-family scope.
  • [P1] The branch currently conflicts with main, so integration behavior and line-level correctness must be refreshed after rebase.
  • [P1] Landing both this generic recovery and fix(github-copilot): strip encrypted_content from reasoning replay items #95493 creates complementary policies; maintainers should explicitly confirm that defense in depth is desired rather than allowing overlapping behavior to drift.

Maintainer options:

  1. Rebase and prove the real retry (recommended)
    Resolve current-main conflicts, then provide redacted live provider or deployment evidence that the retry recovers once without duplicate assistant output.
  2. Accept the continuity tradeoff
    Maintainers may explicitly approve stripping private replay state after rejection and accept mocked transport proof as sufficient defense-in-depth evidence.
  3. Pause behind the Copilot fix
    Hold or close this mitigation if the narrower provider-boundary change is the only policy maintainers want to support.

Next step before merge

  • [P1] A maintainer must resolve the proof override and overlapping retry-policy decision after the contributor rebases; there is no narrow code defect for an automated repair worker.

Maintainer decision needed

  • Question: After the branch is rebased, should the generic Responses drain retry require live provider or deployment proof before merge, or should maintainers explicitly override that gate and accept it as defense in depth beside the Copilot-only provider-boundary fix?
  • Rationale: The code and mocked gates support correctness, but only maintainers can accept the session-continuity tradeoff and waive the repository's external-PR real-setup proof requirement for a provider and session-state change.
  • Likely owner: steipete — The decision extends the one-shot OpenAI Responses retry behavior introduced in steipete's merged transport change and concerns its intended permanent scope.
  • Options:
    • Require live retry proof (recommended): Require redacted output from the rebased PR head showing a real streamed encrypted-content rejection retries once and yields one assistant reply without duplicate lifecycle events.
    • Override for defense in depth: Explicitly accept the mock-backed transport proof and land the generic retry alongside the Copilot provider-boundary prevention path.
    • Prefer provider-boundary only: Pause this PR and first land or reject fix(github-copilot): strip encrypted_content from reasoning replay items #95493, then reassess whether generic drain recovery adds enough value.

Security
Cleared: The diff changes provider retry and error handling plus tests without adding dependencies, permissions, secret access, downloads, workflow execution, or package-resolution changes.

Review details

Best 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 changes

Label justifications:

  • P1: The PR targets a current provider and session failure that can end real channel and direct turns without a deliverable assistant reply.
  • merge-risk: 🚨 session-state: The patch changes how persisted reasoning and signature replay state is discarded and retried, which can alter multi-turn context continuity.
  • rating: 🧂 unranked krab: Overall readiness is 🧂 unranked krab; proof is 🧂 unranked krab and patch quality is 🦞 diamond lobster.
  • status: 📣 needs proof: The PR needs real behavior proof before ClawSweeper can clear the contributor ask. Needs real behavior proof before merge: The new terminal output exercises production helper code with a mocked client and synthetic SSE events, while the live field reports explicitly did not run this PR head; a rebased-head real provider or deployment transcript is still required, with private identifiers and credentials redacted. After adding proof, update the PR body; ClawSweeper should re-review automatically. If it does not, the PR author or someone with repository write access can comment @clawsweeper re-review.
Evidence reviewed

PR surface:

Source +151, Tests +322. Total +473 across 2 files.

View PR surface stats
Area Files Added Removed Net
Source 1 185 34 +151
Tests 1 379 57 +322
Docs 0 0 0 0
Config 0 0 0 0
Generated 0 0 0 0
Other 0 0 0 0
Total 2 564 91 +473

What I checked:

  • Source-confirmed behavior gap: The completed review traced encrypted-content recovery to client.responses.create(), while processResponsesStream can throw response.failed and type:error failures during async stream consumption outside that retry boundary; no merged replacement is identified in the supplied current-main context. (src/agents/openai-transport-stream.ts:2096, 9b823c610816)
  • PR implementation: The branch adds runResponsesDrainWithEncryptedRetry to cover creation and draining, retries at most once only before assistant content is committed, reuses the existing output stream, and sanitizes replay-private encrypted and signature fields before retry. (src/agents/openai-transport-stream.ts:1075, becdaeeacaf4)
  • Focused regression coverage: The added gates cover create and mid-stream failure, nested error codes, retry cap, no retry after emitted content, response-id replacement, one assistant lifecycle, prose matching, and removal of encrypted_content, thinkingSignature, and textSignature. (src/agents/openai-transport-stream.test.ts:4549, becdaeeacaf4)
  • Proof remains mock-only: The July 11 terminal transcript runs the real exported helper but supplies a mocked Responses client and synthetic SSE events; the July 13 field report explicitly says it did not execute this PR head, so neither is after-fix real-provider proof for the external PR gate. (becdaeeacaf4)
  • Provider-boundary sibling: fix(github-copilot): strip encrypted_content from reasoning replay items #95493 remains the narrow Copilot-specific candidate that strips encrypted_content before send; it addresses the provider boundary while this PR supplies generic recovery after rejection. (extensions/github-copilot/connection-bound-ids.ts, b517c2b6994b)
  • Current branch state: GitHub reports the PR as dirty and not cleanly mergeable against current main, so the patch must be rebased and re-reviewed before any merge decision. (becdaeeacaf4)

Likely related people:

  • steipete: Authored the merged invalid reasoning signature recovery that established the create-time one-shot retry this PR extends. (role: introduced adjacent behavior; confidence: high; commits: 2a6eeceb40fe; files: src/agents/openai-transport-stream.ts, src/agents/openai-transport-stream.test.ts)
  • joshavant: Authored the merged provenance-bound encrypted reasoning replay work in the same OpenAI Responses transport and session-state surface. (role: adjacent encrypted replay contributor; confidence: medium; commits: a54c73687f58; files: src/agents/openai-transport-stream.ts, src/agents/openai-transport-stream.test.ts)
  • a410979729-sys: Authored the merged Copilot connection-bound reasoning-ID behavior that the provider-boundary sibling now adjusts. (role: prior Copilot replay contributor; confidence: high; commits: 8fd15ed0e514; files: extensions/github-copilot/connection-bound-ids.ts, extensions/github-copilot/connection-bound-ids.test.ts)
  • NianJiuZst: Reviewed this transport change in depth, found no blocking correctness defect, and requested cleanup that the current head addressed. (role: reviewer; confidence: medium; files: src/agents/openai-transport-stream.ts, src/agents/openai-transport-stream.test.ts)
What the crustacean ranks mean
  • 🦀 challenger crab: rare, exceptional readiness with strong proof, clean implementation, and convincing validation.
  • 🦞 diamond lobster: very strong readiness with only minor maintainer review expected.
  • 🐚 platinum hermit: good normal PR, likely mergeable with ordinary maintainer review.
  • 🦐 gold shrimp: useful signal, but proof or patch confidence is still limited.
  • 🦪 silver shellfish: thin signal; proof, validation, or implementation needs work.
  • 🧂 unranked krab: not merge-ready because proof is missing/unusable or there are serious correctness or safety concerns.
  • 🌊 off-meta tidepool: rating does not apply to this item.

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
  • ClawSweeper keeps one durable marker-backed review comment per issue or PR.
  • Re-runs edit this comment so the latest verdict, findings, and automation markers stay together instead of adding duplicate bot comments.
  • A fresh review can be triggered by eligible @clawsweeper re-review comments, exact-item GitHub events, scheduled/background review runs, or manual workflow dispatch.
  • PR/issue authors and users with repository write access can comment @clawsweeper re-review or @clawsweeper re-run on an open PR or issue to request a fresh review only.
  • Maintainers can also comment @clawsweeper review to request a fresh review only.
  • Fresh-review commands do not start repair, autofix, rebase, CI repair, or automerge.
  • Maintainer-only repair and merge flows require explicit commands such as @clawsweeper autofix, @clawsweeper automerge, @clawsweeper fix ci, or @clawsweeper address review.
  • Maintainers can comment @clawsweeper explain to ask for more context, or @clawsweeper stop to stop active automation.
Review history (1 earlier review cycle)
  • reviewed 2026-06-28T13:57:10.340Z sha becdaee :: needs real behavior proof before merge. :: none

@clawsweeper clawsweeper Bot added rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. P1 High-priority user-facing bug, regression, or broken workflow. merge-risk: 🚨 session-state 🚨 May lose, corrupt, stale, or mis-associate session, agent, or context state. labels Jun 21, 2026

@NianJiuZst NianJiuZst left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. createResponsesStreamWithEncryptedContentRetry is now dead in the production path. createOpenAIResponsesTransportStreamFn was rewired to call runResponsesDrainWithEncryptedRetry instead, which subsumes both the pre-stream and the mid-stream cases via a single catch. The pre-stream helper is still exported via testing but 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 testing surface / future internal reuse would be enough.

  2. isInvalidEncryptedContentError carries a depth parameter on the public surface. The exported function now defaults depth = 0 purely for the recursive walk. Minor style smell — the public matcher shouldn't carry recursion plumbing. A private _isInvalidEncryptedContentError(error, depth) with a depth = 0-defaulting public wrapper reads cleaner. Trivial.

  3. 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 extra create() 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.

  4. ResponsesStreamFailureError is the right level of abstraction. Carries code + responseId so the drain-level retry detector doesn't have to re-parse message strings. Both throw sites (type: "error", response.failed) now use it. Good.

  5. 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.code only) — would leave Gate 8's production path (nested type: "error") uncovered, which is the actual #95441 surface. Wrong.
  • Strip thinkingSignature / textSignature at 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 (new describe block lines 4627–5036; compared against main 11294-line file at line 4534 for the pre-existing stripResponsesRequestEncryptedContent test)
  • 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 not isInvalidEncryptedContentError, 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 read output post-hoc. Reading processResponsesStream end-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 asserts output.responseId matches 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.

PR: #95587
Issue: #95441

…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
@fanyangCS
fanyangCS force-pushed the pr/responses-drain-encrypted-retry branch from f3f9a49 to becdaee Compare June 22, 2026 01:54
@fanyangCS

Copy link
Copy Markdown
Author

@NianJiuZst thanks for the review — addressed the pre-stream helper disposition: deleted createResponsesStreamWithEncryptedContentRetry. runResponsesDrainWithEncryptedRetry already subsumes the pre-stream create() failure and the mid-stream response.failed/type:"error" failure via one catch, so there's no longer a silently-unused export. Removed its redundant unit test + the lone import OpenAI it needed; the create-failure path stays covered by the drain gates. Re-verified: drain gates 22 passed, oxlint clean.

The depth param + prose-heuristic notes are fair follow-ups; I left them as-is for this mitigation to keep the diff minimal, but happy to tighten in a follow-up if you'd prefer.

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 start) for the ClawSweeper proof gate. Pushed as becdaeeaca.

@clawsweeper re-review

@clawsweeper

clawsweeper Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

🦞🧹
ClawSweeper re-review requested.

I asked ClawSweeper to review this item again.
Action: item re-review queued (workflow sweep.yml, event repository_dispatch).
Result: the existing ClawSweeper review comment will be edited in place when the review finishes.

@clawsweeper clawsweeper Bot added rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. and removed rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. labels Jun 22, 2026
@snotty

snotty commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Field report from an affected v2026.6.10 install, in case it helps with the real-behavior/proof discussion around #95441.

Observed surface:

  • Provider/model: github-copilot/gpt-5.5
  • Session type: Telegram direct/channel-style session
  • Symptom: later turns failed with the Copilot encrypted-content replay error class (encrypted_content / could not be verified) before a normal assistant reply

Local mitigation proof:

  • I applied a local startup hotfix to the installed runtime that strips Copilot encrypted reasoning replay/storage material before gateway start.
  • After restarting the gateway through that hook, the same Telegram session completed a smoke test successfully on github-copilot/gpt-5.5.
  • The active session JSONL and trajectory files both had encrypted_content count 0 afterward.

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.

@clawsweeper clawsweeper Bot added rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. and removed rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. labels Jul 1, 2026
@clawsweeper

clawsweeper Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

@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.

@fanyangCS

Copy link
Copy Markdown
Author

Real-behavior proof (test-run output at PR head becdaeeaca)

@clawsweeper — adding the requested runtime proof. The PR body previously carried the script for the drain gate; here is the actual terminal output of running the real exported drain path (testing.runResponsesDrainWithEncryptedRetry / processResponsesStream) at this PR's head SHA, plus a clean lint run.

Checked out at becdaeeacaf440cd29f914606448394539be98a7 (this PR's head), fresh worktree.

Drain-level encrypted-content retry gates (#95441) — all pass

$ vitest run src/agents/openai-transport-stream.test.ts -t "drain-level encrypted-content retry" --reporter=verbose

 ✓ drain-level encrypted-content retry (#95441) > retries once on empty-content failure and emits a single start  10ms
 ✓ drain-level encrypted-content retry (#95441) > does not retry once content has already streamed  1ms
 ✓ drain-level encrypted-content retry (#95441) > does not retry when there is nothing to strip  1ms
 ✓ drain-level encrypted-content retry (#95441) > retries at most once even if the sanitized attempt fails  1ms
 ✓ drain-level encrypted-content retry (#95441) > surfaces the retry response id, not the failed one  1ms
 ✓ drain-level encrypted-content retry (#95441) > produces exactly one assistant lifecycle (no double done)  1ms
 ✓ drain-level encrypted-content retry (#95441) > matches nested error shapes and surfaces the failed-event code  0ms
 ✓ drain-level encrypted-content retry (#95441) > retries on a nested type:error encrypted-content event  1ms
 ✓ drain-level encrypted-content retry (#95441) > normalizes flat and nested type:error code shapes  0ms
 ✓ drain-level encrypted-content retry (#95441) > matches the bare provider 400 prose with no structured code  0ms
 ✓ drain-level encrypted-content retry (#95441) > strips thinkingSignature and textSignature, not just encrypted_content  0ms

 Test Files  2 passed (2)
      Tests  22 passed | 542 skipped (564)
   Duration  2.88s

(11 named gates × 2 project shards agents-core + agents-support = 22 passing.)

These gates drive the real behavior, not just matcher units:

  • fail→strip→single-retry→recover: create() returns a response.failed with invalid_encrypted_content and empty content → the drain helper strips the encrypted payload and re-issues once into the same started stream, recovering the reply. Exactly one start event (no duplicate partial).
  • anti-double-emit: if any assistant content already streamed, it rethrows instead of retrying.
  • nested type:"error" and flat/nested code normalization both trigger the retry.
  • bare 400 prose (no structured code) is matched.
  • sanitized retry strips thinkingSignature / textSignature in addition to nested encrypted_content.

Lint — clean

$ oxlint src/agents/openai-transport-stream.ts src/agents/openai-transport-stream.test.ts
Found 0 warnings and 0 errors.
Finished in 32ms on 2 files with 208 rules using 8 threads.

Note on the red CI checks: the failing checks-node-core-* shards fail on resolve-openclaw-ref (git ls-remote) / bench-gateway-restart (spawn ENOENT) / test-projects — CI-env flakes on files this PR never touches. The agents-core / agents-support shards that actually own this file are the ones shown passing above.

@clawsweeper re-review

@snotty

snotty commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Complementary live evidence: why create-only recovery missed the real failure

I reproduced the canonical #95441 failure on OpenClaw 2026.7.1 with github-copilot/gpt-5.6-sol in an aged Telegram direct session.

The installed transport already had a create-time encrypted-content matcher/retry. Nevertheless, after a full gateway restart the resumed turn still produced:

HTTP 400
The encrypted content … could not be verified.
Reason: Encrypted content could not be decrypted or parsed.

The embedded runner then retried the same poisoned request three times and surfaced LLM request failed.

Code-path inspection confirmed the gap described by this PR:

  • createResponsesStreamWithEncryptedContentRetry() wrapped only client.responses.create()
  • processResponsesStream() consumed the returned async stream separately
  • an encrypted-content error surfaced during async stream consumption bypassed the create-time catch entirely

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, empty-error-retry, or LLM request failed events.

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 becdaeeaca's retry helper. It is, however, real provider/deployment evidence that:

  1. the failure can escape a create-only catch on gpt-5.6-sol; and
  2. the final provider-boundary fix and drain-level recovery solve complementary layers of the same bug.

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.

Cross-reference: #95441 and #95493.

@snotty

snotty commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Updated recommendation: prefer the combined fix in #107266

Following 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:

  1. provider-owned cold/replayed reasoning must be sanitized so stale Copilot ciphertext is not sent after session reconstruction; and
  2. create-time and streamed encrypted-content failures need one bounded, pre-output recovery path.

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 github-copilot/gpt-5.6-sol Telegram session environment to help validate #107266, including full-process restart/cold-resume behavior, session-ID continuity, provider HTTP outcomes, duplicate-output checks, and confirmation that same-loop tool continuation still works. I’ll coordinate that proof on #107266 rather than duplicating it across the narrower PRs.

visionclaw pushed a commit to bryanoliveira/openclaw that referenced this pull request Jul 22, 2026
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)
@clawsweeper

clawsweeper Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

@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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling merge-risk: 🚨 session-state 🚨 May lose, corrupt, stale, or mis-associate session, agent, or context state. P1 High-priority user-facing bug, regression, or broken workflow. rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. size: L status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. triage: needs-pr-context Candidate: external PR body lacks required problem context or evidence.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

WA business, groups & office hours

3 participants