Skip to content

fix: Responses streams mishandle interleaved output items and silent truncation#109615

Merged
steipete merged 3 commits into
mainfrom
fix/responses-stream-hardening
Jul 17, 2026
Merged

fix: Responses streams mishandle interleaved output items and silent truncation#109615
steipete merged 3 commits into
mainfrom
fix/responses-stream-hardening

Conversation

@steipete

@steipete steipete commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

What Problem This Solves

Fixes a set of OpenAI Responses-protocol streaming defects that corrupt reasoning replay and hide stream truncation:

  • Streams that interleave output items (multiple in-flight output_indexes) lose reasoning deltas or attach a thinking signature to the wrong block, breaking multi-turn reasoning replay.
  • A stream that dies without a terminal event is reported as a clean "stop": the response silently truncates and usage accounting is lost. response.incomplete was not handled at all.
  • Reasoning items whose encrypted_content only arrives in the terminal response.completed payload (Azure behavior) cannot be replayed statelessly.
  • output_item.done with content: null (some OpenAI-compatible servers) crashes the stream mid-flight.
  • max_output_tokens below 16 is rejected by the API with a 400.
  • Responses-compatible providers that reject the developer role could not run reasoning models.
  • Azure responses were stored server-side by default (store: false was only set on the plain OpenAI path).
  • Session headers longer than 64 chars are rejected by the ChatGPT backend; inner transport retries ignored the caller's retry budget, stacking with the app-level retry policy; and the SDK's silent default retries (2) ran underneath app retries.

Why This Change Was Made

The single-slot stream state machine assumed strictly sequential output items, which the Responses protocol does not guarantee. Tracking per-output_index slots makes interleaving safe, and requiring an explicit terminal event turns silent truncation into a classified, retryable error.

User Impact

  • Reasoning models over Responses (OpenAI, Azure, ChatGPT-backend) keep their thinking blocks intact across turns, including out-of-order streams and stateless replay.
  • Truncated streams surface as errors with usage preserved instead of silently succeeding with missing content.
  • Reasoning models work on providers without developer-role support; Azure gains store: false privacy parity; retry behavior is fully owned by the app's policy.

Evidence

  • node scripts/run-vitest.mjs packages/ai/src/providers/openai-responses-shared.test.ts packages/ai/src/providers/openai-responses.test.ts packages/ai/src/providers/azure-openai-responses.test.ts packages/ai/src/providers/openai-chatgpt-responses.test.ts — 4 files, 113 tests passing, including new cases for interleaved reasoning by output index, deferred text across interleaved items, terminal encrypted-content backfill, response.incomplete with usage, content-filter classification, missing-terminal-event error, header clamping, and retry-budget honoring.
  • Changed-file typecheck/lint/format gates green (node scripts/check-changed.mjs).

Live verification (real API keys): node scripts/test-live.mjs -- packages/ai/src/providers/openai-responses.live.test.ts — 3/3 passing against the live OpenAI Responses API: terminal-event usage accounting, sub-floor max_output_tokens clamp accepted (previously a 400), and an interleaved reasoning + tool-call stream keeping items separable with intact thinking signatures. Live suite added in this PR (gated on OPENCLAW_LIVE_TEST=1 + key).

@openclaw-barnacle openclaw-barnacle Bot added size: XL maintainer Maintainer-authored PR labels Jul 17, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5de387f772

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +995 to +996
output.stopReason = "error";
output.errorMessage = "Provider incomplete_reason: content_filter";

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.

P2 Badge Route content-filtered ChatGPT responses to error

When the ChatGPT Responses transport receives response.incomplete with incomplete_details.reason === "content_filter", this new branch makes processResponsesStream return normally with output.stopReason = "error". The ChatGPT caller does not use runResponsesStreamLifecycle; after processStream/processWebSocketStream it unconditionally pushes a done event, so SSE/WebSocket ChatGPT content-filter failures are surfaced as completed turns to consumers that branch on terminal event type. Add the same post-process error check used by the OpenAI/Azure lifecycle before emitting done.

Useful? React with 👍 / 👎.

@steipete
steipete force-pushed the fix/responses-stream-hardening branch from 5de387f to 82b807c Compare July 17, 2026 03:56
@clawsweeper clawsweeper Bot added rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. P1 High-priority user-facing bug, regression, or broken workflow. merge-risk: 🚨 compatibility 🚨 May break existing users, config, migrations, defaults, or upgrade paths. merge-risk: 🚨 availability 🚨 May cause crashes, hangs, restart loops, stalls, or process outages. labels Jul 17, 2026
@clawsweeper

clawsweeper Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Codex review: found issues before merge. Reviewed July 17, 2026, 1:50 AM ET / 05:50 UTC.

Summary
Reworks OpenAI Responses stream state tracking and terminal handling while changing reasoning replay, provider-role compatibility, Azure storage, token limits, session headers, retries, and live-test discovery.

PR surface: Source +228, Tests +740. Total +968 across 11 files.

Reproducibility: yes. at the source-contract level: current main's single-slot parsing and clean completion behavior are directly traceable, and the PR includes focused regression cases plus live OpenAI after-fix proof. This review did not independently execute a failing current-main live stream.

Review metrics: 1 noteworthy metric.

  • Responses compatibility API: 1 field added. The new supportsDeveloperRole field changes the public provider compatibility surface and needs an intentional default and upgrade contract.

Merge readiness
Overall: 🦐 gold shrimp
Proof: 🦞 diamond lobster
Patch quality: 🦐 gold shrimp
Result: needs maintainer review before merge.

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

Rank-up moves:

  • [P2] Fix ChatGPT SSE and WebSocket error finalization and add focused response.incomplete regression coverage.
  • Refresh the branch against current main and rerun the focused Responses provider suite.
  • Provide focused real-path confirmation for the highest-risk non-OpenAI behavior, preferably ChatGPT incomplete handling or Azure terminal replay.

Risk before merge

  • [P1] ChatGPT SSE and WebSocket content-filter or other incomplete responses can still be published as normal done terminal events, causing consumers to treat failed or truncated turns as successful.
  • [P1] The PR changes retry ownership, Azure storage, role selection, session-header values, and output-token normalization across provider paths; unit coverage is extensive, but the live proof directly exercises only the OpenAI Responses path.
  • [P1] The branch is behind current main, so the final provider-runtime review and validation need refresh against the actual merge result.

Maintainer options:

  1. Unify terminal error finalization (recommended)
    Before merge, route ChatGPT SSE and WebSocket error stop reasons through the same error-event contract as OpenAI and Azure, with focused regression coverage.
  2. Split the provider-default changes
    Separate the core interleaving and truncation fix from retry, role, Azure storage, token-floor, and header behavior if cross-provider upgrade proof cannot be supplied together.
  3. Pause for provider owner review
    Keep the PR open until the intended retry ownership and compatibility defaults are explicitly confirmed by the provider-runtime owner.

Next step before merge

  • [P2] The protected maintainer label and compatibility-sensitive provider defaults require explicit owner review; the contributor should first fix the repeated ChatGPT terminal-event blocker.

Maintainer decision needed

  • Question: After the ChatGPT terminal-event defect is fixed, should the combined retry, storage, role, token-floor, and session-header behavior changes land together in this provider-runtime PR?
  • Rationale: These changes alter several compatibility-sensitive provider defaults and contracts; tests can prove mechanics, but an owner must confirm the intended cross-provider upgrade policy and acceptable rollout scope.
  • Likely owner: vincentkoc — The provider transport adapter and surrounding Responses runtime history point to Vincent Koc as the strongest available owner for the cross-provider contract decision.
  • Options:
    • Fix and prove combined change (recommended): Keep the cohesive stream-hardening PR, fix ChatGPT error finalization, refresh against main, and require focused OpenAI, Azure, and ChatGPT compatibility proof before merge.
    • Split compatibility changes: Land the per-index and terminal-state repair separately, then review retry, role, storage, token-floor, and header changes as narrower follow-up PRs.
    • Accept current scope: Merge the broad provider behavior changes after the terminal fix while explicitly accepting that some non-OpenAI paths have only mocked coverage.

Security
Cleared: No concrete security or supply-chain regression was found; the diff changes provider request and stream behavior without adding dependencies, downloaded code, permissions, secrets access, or publishing hooks.

Review findings

  • [P2] Emit an error event for ChatGPT incomplete responses — packages/ai/src/providers/openai-responses-shared.ts:667
Review details

Best possible solution:

Centralize terminal stream finalization so OpenAI, Azure, and ChatGPT callers all emit an error event for stopReason === "error", then retain the per-output-index parser with focused SSE/WebSocket, fresh-install, and upgrade-compatible provider coverage.

Do we have a high-confidence way to reproduce the issue?

Yes at the source-contract level: current main's single-slot parsing and clean completion behavior are directly traceable, and the PR includes focused regression cases plus live OpenAI after-fix proof. This review did not independently execute a failing current-main live stream.

Is this the best way to solve the issue?

No, not yet. Per-output-index state and explicit terminal enforcement are the right layer, but terminal finalization must be shared or consistently applied by the ChatGPT SSE/WebSocket callers before this is the best fix.

Full review comments:

  • [P2] Emit an error event for ChatGPT incomplete responses — packages/ai/src/providers/openai-responses-shared.ts:667
    processResponsesStream now classifies response.incomplete as stopReason = "error", but the ChatGPT SSE/WebSocket path does not use runResponsesStreamLifecycle and still finalizes by pushing done. Consumers therefore see content-filtered or otherwise incomplete ChatGPT turns as completed. This is the prior blocker and remains unchanged on the latest head; add the same post-process error handling for both ChatGPT transports before emitting done.
    Confidence: 0.99

Overall correctness: patch is incorrect
Overall confidence: 0.98

AGENTS.md: found and applied where relevant.

Codex review notes: model internal, reasoning high; reviewed against f977649065cd.

Label changes

Label justifications:

  • P1: The PR addresses reasoning corruption and silent stream truncation in active provider workflows, but currently retains a terminal-event defect that can misclassify failed ChatGPT turns.
  • merge-risk: 🚨 compatibility: The branch changes provider retry behavior, role selection, output-token normalization, Azure storage, session headers, and a public compatibility type.
  • merge-risk: 🚨 availability: Incorrect terminal propagation or retry ownership can turn provider failures into false successes or alter recovery behavior during transient outages.
  • rating: 🦐 gold shrimp: Overall readiness is 🦐 gold shrimp; proof is 🦞 diamond lobster and patch quality is 🦐 gold shrimp.
  • status: ⏳ waiting on author: ClawSweeper has contributor-facing work open and is waiting for author action. Sufficient (live_output): The PR provides after-fix live OpenAI API output for terminal accounting, token-floor acceptance, and interleaved reasoning/tool streams; this is strong proof for the central behavior, while the remaining ChatGPT defect is a correctness blocker rather than an absent-proof classification.
  • proof: sufficient: Contributor real behavior proof is sufficient. The PR provides after-fix live OpenAI API output for terminal accounting, token-floor acceptance, and interleaved reasoning/tool streams; this is strong proof for the central behavior, while the remaining ChatGPT defect is a correctness blocker rather than an absent-proof classification.
Evidence reviewed

PR surface:

Source +228, Tests +740. Total +968 across 11 files.

View PR surface stats
Area Files Added Removed Net
Source 4 472 244 +228
Tests 7 746 6 +740
Docs 0 0 0 0
Config 0 0 0 0
Generated 0 0 0 0
Other 0 0 0 0
Total 11 1218 250 +968

What I checked:

  • Prior blocker remains: The previous ClawSweeper cycle found that ChatGPT Responses callers can emit a done event after processResponsesStream classifies response.incomplete as an error; the only commit after that reviewed SHA changes the live-test glob assertion, not provider runtime behavior. (src/infra/vitest-live-config.test.ts:25, c63e5020a77f)
  • One-sided terminal handling: The shared OpenAI/Azure lifecycle explicitly converts stopReason === "error" into an exception, but the ChatGPT SSE/WebSocket caller has its own finalization path, so changing the shared parser alone does not guarantee an error terminal event across sibling transports. (packages/ai/src/providers/openai-responses-shared.ts:667, c63e5020a77f)
  • Direct Codex contract check: OpenAI Codex's Responses client streams until a terminal response, and its core compaction path reports stream closed before response.completed when the stream ends first; this supports treating missing terminal completion as an error rather than a clean stop. (../codex/codex-rs/core/src/compact.rs)
  • Direct Codex request-path check: Codex builds session/request headers in codex-rs/codex-api/src/endpoint/responses.rs and passes the SSE response to spawn_response_stream, confirming that session affinity and terminal stream semantics are transport contracts rather than presentation details. (../codex/codex-rs/codex-api/src/endpoint/responses.rs:695)
  • Real API proof: The PR body reports three passing live OpenAI Responses cases covering terminal usage accounting, the output-token floor, and interleaved reasoning/tool output with intact signatures. (packages/ai/src/providers/openai-responses.live.test.ts:1, e46f9d84d2d7)
  • Provider compatibility API change: The branch adds supportsDeveloperRole to the public Responses compatibility type and uses it to select system instead of developer, making this an upgrade-sensitive provider contract change. (packages/llm-core/src/types.ts:478, 82b807c1c227)

Likely related people:

  • vincentkoc: Repository blame/history attributes the OpenAI transport adapter seam and subsequent hardening and usage-accounting work to Vincent Koc in the provider runtime area. (role: introduced transport seam and recent area contributor; confidence: high; commits: 4e27e2266358; files: packages/ai/src/providers/openai-responses-shared.ts, packages/ai/src/providers/openai-chatgpt-responses.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 (3 earlier review cycles)
  • reviewed 2026-07-17T04:02:12.652Z sha 82b807c :: needs real behavior proof before merge. :: [P2] Emit an error event for ChatGPT content-filter incompletes
  • reviewed 2026-07-17T04:44:26.044Z sha 82b807c :: needs real behavior proof before merge. :: [P2] Emit an error event for ChatGPT incomplete responses
  • reviewed 2026-07-17T05:36:44.063Z sha e46f9d8 :: found issues before merge. :: [P2] Emit an error event for ChatGPT incomplete responses

@clawsweeper clawsweeper Bot added rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. and removed rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. labels Jul 17, 2026
@clawsweeper clawsweeper Bot added proof: sufficient ClawSweeper judged the real behavior proof convincing. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. and removed rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. labels Jul 17, 2026
@steipete
steipete merged commit 03d287d into main Jul 17, 2026
120 checks passed
@steipete
steipete deleted the fix/responses-stream-hardening branch July 17, 2026 06:15
@steipete

Copy link
Copy Markdown
Contributor Author

Merged via squash.

github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request Jul 18, 2026
…truncation (openclaw#109615)

* fix(ai): track Responses output items per index and require terminal stream events

* test(ai): add live Responses stream coverage

* test(infra): mirror packages live glob in live-config test
steipete added a commit that referenced this pull request Jul 18, 2026
…nd zero cost (#109904)

* fix(agents): record token usage when a Responses turn ends incomplete

The agent-side Responses processor only had a terminal branch for
response.completed. A stream that ends with response.incomplete — the
max_output_tokens and content_filter cases, and what Azure emits on early
truncation — matched no branch at all, so the event was dropped: usage was
never recorded and stopReason was never set. The turn reports zero tokens and
zero cost, which is the drift in #100954.

#109615 fixed the same split on the package-side processor by finalizing
completed and incomplete through one finalizeResponse. This does the same for
the agent path, which #109615 did not touch: both terminal events now record
usage, cost and service-tier pricing through one helper.

The helper moves to its own module rather than growing openai-responses-transport.ts,
which is a legacy file the max-lines ratchet will not let grow; extracting the
block drops it from 2502 to 2444 lines.

Content-filtered turns are mapped to a provider error instead of a plain length
stop, matching what the package side already does, so the two terminal surfaces
do not disagree.

* fix(agents): keep Responses output backfill on completed turns

Terminal handling now covers response.incomplete, which also routed those
events into backfillCompletedResponseOutput. That reconstruction exists to
recover a final answer when item events never arrived; an incomplete turn has
no final answer, so replaying its partial output persisted truncated text the
streaming path never emitted. Usage and stop-reason recording still apply to
both terminal events.

* refactor(ai): share one Responses terminal usage mapper

The agent transport mapped terminal usage buckets, cost, and stop reasons in
parallel with the package-side processor, so the two could drift on token
buckets, service-tier pricing, or future terminal-event semantics.

Both now call one canonical mapper. openai-responses-shared.ts cannot be
imported across the package boundary, so the mapper lives in its own module
re-exported through the existing internal/openai entry point; no new subpath
is introduced. The agent module keeps only the reasoning-token accounting the
package path does not track.

Merging the two revealed a real disagreement on totalTokens: the package took
the reported total, the agent summed the split buckets. The canonical rule is
now max(bucket sum, reported total), which keeps the reported value while
covering both payloads that omit total_tokens and payloads whose cached_tokens
exceed input_tokens, where clamping leaves the reported total short.

* fix(agents): preserve partial incomplete Responses output

Co-authored-by: Yigtwxx <[email protected]>

* test(agents): cover incomplete terminal output backfill boundaries

The head's partial-output preservation is only safe while it stays a recovery
path. Pin both sides of that guard: text that already streamed must not be
replayed from the terminal payload, and a non-length incomplete stop must not
surface partial text as an answer.

* fix(agents): avoid shadowing Responses options

Co-authored-by: Yigtwxx <[email protected]>

* style(agents): apply oxfmt to the incomplete backfill test

* test(agents): register incomplete Responses suite

Co-authored-by: Yiğit ERDOĞAN <[email protected]>

---------

Co-authored-by: Peter Steinberger <[email protected]>
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request Jul 19, 2026
…nd zero cost (openclaw#109904)

* fix(agents): record token usage when a Responses turn ends incomplete

The agent-side Responses processor only had a terminal branch for
response.completed. A stream that ends with response.incomplete — the
max_output_tokens and content_filter cases, and what Azure emits on early
truncation — matched no branch at all, so the event was dropped: usage was
never recorded and stopReason was never set. The turn reports zero tokens and
zero cost, which is the drift in openclaw#100954.

openclaw#109615 fixed the same split on the package-side processor by finalizing
completed and incomplete through one finalizeResponse. This does the same for
the agent path, which openclaw#109615 did not touch: both terminal events now record
usage, cost and service-tier pricing through one helper.

The helper moves to its own module rather than growing openai-responses-transport.ts,
which is a legacy file the max-lines ratchet will not let grow; extracting the
block drops it from 2502 to 2444 lines.

Content-filtered turns are mapped to a provider error instead of a plain length
stop, matching what the package side already does, so the two terminal surfaces
do not disagree.

* fix(agents): keep Responses output backfill on completed turns

Terminal handling now covers response.incomplete, which also routed those
events into backfillCompletedResponseOutput. That reconstruction exists to
recover a final answer when item events never arrived; an incomplete turn has
no final answer, so replaying its partial output persisted truncated text the
streaming path never emitted. Usage and stop-reason recording still apply to
both terminal events.

* refactor(ai): share one Responses terminal usage mapper

The agent transport mapped terminal usage buckets, cost, and stop reasons in
parallel with the package-side processor, so the two could drift on token
buckets, service-tier pricing, or future terminal-event semantics.

Both now call one canonical mapper. openai-responses-shared.ts cannot be
imported across the package boundary, so the mapper lives in its own module
re-exported through the existing internal/openai entry point; no new subpath
is introduced. The agent module keeps only the reasoning-token accounting the
package path does not track.

Merging the two revealed a real disagreement on totalTokens: the package took
the reported total, the agent summed the split buckets. The canonical rule is
now max(bucket sum, reported total), which keeps the reported value while
covering both payloads that omit total_tokens and payloads whose cached_tokens
exceed input_tokens, where clamping leaves the reported total short.

* fix(agents): preserve partial incomplete Responses output

Co-authored-by: Yigtwxx <[email protected]>

* test(agents): cover incomplete terminal output backfill boundaries

The head's partial-output preservation is only safe while it stays a recovery
path. Pin both sides of that guard: text that already streamed must not be
replayed from the terminal payload, and a non-length incomplete stop must not
surface partial text as an answer.

* fix(agents): avoid shadowing Responses options

Co-authored-by: Yigtwxx <[email protected]>

* style(agents): apply oxfmt to the incomplete backfill test

* test(agents): register incomplete Responses suite

Co-authored-by: Yiğit ERDOĞAN <[email protected]>

---------

Co-authored-by: Peter Steinberger <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

maintainer Maintainer-authored PR merge-risk: 🚨 availability 🚨 May cause crashes, hangs, restart loops, stalls, or process outages. merge-risk: 🚨 compatibility 🚨 May break existing users, config, migrations, defaults, or upgrade paths. P1 High-priority user-facing bug, regression, or broken workflow. proof: sufficient ClawSweeper judged the real behavior proof convincing. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. size: XL status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant