Conversation
…fail terminal Phase 1 replays OpenAI reasoning by encrypted content only. Two rejections survive it and repeat deterministically for the same input: a 400/404 "Item with id 'rs_…' not found" for a reference the route cannot resolve, and `invalid_encrypted_content` / "encrypted content … could not be verified" for a blob minted under another org or route. Both fell into the generic `api` class, so the outer RetryManager resent the identical request forever. StreamManager now mirrors the previousResponseId recovery: on a strictly matched rejection from the OpenAI Responses wire it strips every OpenAI reasoning part from the failing request (`stripOpenAIReasoningReplay`, non mutating, identity on no-op) and restarts the current step once, under the same safety envelope (no abort/soft interrupt pending, no parts emitted by the current step, SDK step snapshot required after completed steps). Prior-step text, tool results and usage are preserved. A matching rejection that still reaches failure handling — repeated, unsafe, or nothing to strip — is classified with the new terminal `reasoning_rejected` type, which joins NON_RETRYABLE_STREAM_ERRORS and RUNNING_TASK_TERMINAL_STREAM_ERRORS so RetryManager, WorkspaceTurnManager and TaskService all stop; later 503/401/429 keep their ordinary classes. Provenance keys on the resolved model instance (`openai.responses`, or the Xum gateway fronting `openai/` models), not the requested model string, so xAI Responses and chat-completions routes never match. Nothing persists across attempts: a manual continuation gets its own repair. Tests: strip helper (removal, other providers, string/empty assistants, identity); end-to-end processStream runs for both rejection shapes (one repair, no intermediate error; repeated → reasoning_rejected; later 503 ordinary; unsafe repair still terminal; strict negatives incl. xAI/chat wire; gateway eligibility); step-boundary repair preserving parts/usage; terminal-class behaviour in RetryManager, WorkspaceTurnManager, TaskService and startup eligibility. _Generated with `xum` • Model: `coder:anthropic/claude-fable-5-1` • Thinking: `xhigh`_ <!-- mux-attribution: model=coder:anthropic/claude-fable-5-1 thinking=xhigh --> Signed-off-by: Thomas Kosiewski <tk@coder.com>
Cover post-repair authentication and rate-limit failures; shorten repeated recovery comments without changing logic. _Generated with `xum` • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$122.59`_ <!-- mux-attribution: model=coder:openai/gpt-6-astra thinking=high costs=122.59 -->
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: eae7090063
ℹ️ 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".
Prefer the SDK-prepared transcript even before output so reasoning recovery does not restore context removed by compaction or a thinking rebuild. Add red-first regressions for both rejection forms. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$150.32`_ <!-- mux-attribution: model=coder:openai/gpt-6-astra thinking=high costs=150.32 -->
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 006ce021e5
ℹ️ 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".
Recognize narrowly identified SDK stream-error 500s and inspect only the final cause after SDK retry exhaustion. Preserve ordinary HTTP error policy. Add real SDK decoding and bounded recovery regressions. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$194.25`_ <!-- mux-attribution: model=coder:openai/gpt-6-astra thinking=high costs=194.25 -->
Include the required typed request and assert successful stream completion so notification tests cannot hide fixture failures behind already-emitted tool events. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$201.04`_ <!-- mux-attribution: model=coder:openai/gpt-6-astra thinking=high costs=201.04 -->
Paused: not ready
The independent advisory recommendation was to repair the bounded fixture issue locally, then stop rather than trigger assessments seven and eight automatically. Another push requires review-budget authorization. The repository readiness helper also still requires a manual trigger; that requirement needs an explicit decision rather than fabricating a request or silently exceeding the cap. The deployment owner must separately adjudicate the audit event before remote UAT resumes. Decision: blocked, not ready with follow-ups. No merge or auto-merge. Logs, media, red/green receipts, the local commit, and the preserved remote chat remain available for continuation. Generated with |
Forward metadata read options through the same targeted reader as AIService. Include project Map entries in no-write snapshots and await abandoned reads before teardown. This prevents test-only migrations from racing deletion of the temporary config root. --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$260.11`_ <!-- mux-attribution: model=coder:openai/gpt-6-astra thinking=high costs=260.11 -->
Summary
Recover once when OpenAI Responses rejects replayed reasoning. Strip only the request's OpenAI reasoning parts, preserve visible text and tool history, and retry inside the existing stream. If the rejection repeats—or repair would be unsafe—classify it as terminal so automatic retries and delegated tasks cannot loop forever.
Second layer of #4335. The first layer avoids stale server-side references. This layer handles rejected encrypted blobs and stale reasoning IDs in intra-turn SDK steps.
Resumed for merge preparation: both CI fixture repairs are now published. Local static checks and 1,526 targeted tests pass; full CI and final-head reviews remain pending. The maintainer requested continuation and merge, so a bounded final review cycle will proceed with the prior six assessments retained in the count. Remote UAT remains halted pending human adjudication of the audit event; merge is still gated on accepted final-head UAT evidence.
Implementation
StreamProviderError, orAPICallErrorwith SSE content type and anerror/response.failedframe. Ordinary HTTP 500s retain their policy.reasoning_rejectedfor a final matching failure. Shared retry eligibility, delegated workspace turns, and running tasks treat it as terminal. Manual continuation gets a fresh attempt.No history rewrite, sticky flag, migration, global retry cap, or UI change. Read the diff in this order:
reasoningProviderOptions.ts→streamManager.ts→ terminal-error consumers → tests. Most of the diff is regression coverage; repair and terminal handling remain one cohesive guarantee.Validation
Current head:
b398e7834ad9a0ec7eabec51440717d2aec3e84a.make static-checkand 1,526 targeted tests passed (776 recovery/notification tests plus the full 750-test TaskService suite). Red-first receipts cover repair and terminal classification. The stream-error regression uses the real OpenAI SDK with a local HTTP-200 SSE fixture, including SDK retry exhaustion; it is not a live WebSocket-network test.Historical real-provider UAT and media — not evidence for the current head
Round 3 passed on predecessor
eae709006through the Coder AI gateway. A persisted blob was changed toblob-broken. The provider rejected it with HTTP 400invalid_encrypted_content; one repair log was emitted; the captured successful request contained no reasoning or item references and retained prior text. Existing history rows remained byte-identical. A second user turn repeated successful repair. Screenshots and independently decoded video frames showed no error/retry barrier. Four isolation audits found no shared-deployment mutations.Round 4 tested predecessor
006ce021e, including two prepared-first-step unit tests and two successful real-provider repair turns. Its endorsed verdict is BLOCKED, not PASS: an API-key deletion under the test user's identity appeared in the audit window and remains unattributed. The serving process also retained unused Anthropic provider variables; this is recorded as a same-environment run, not clean-provider evidence. The corrective run was cancelled without being sent. Owned server/browser processes were cleaned up; the shared workspace was left untouched.Evidence limits: failed pre-stream requests have no raw HTTP body in existing devtools. Marker transmission is corroborated by recorded SDK input and the provider's truncated marker echo, not by a captured rejected wire body. Exact network-request count is inferred from step records plus repair logs. The second-rejection terminal path is automated-test coverage only. This was AI-gateway-backed traffic, not direct
api.openai.com.Independent UAT chat. Local evidence:
.mux-uat/round-3/and.mux-uat/round-4/; validation pointers:.mux-uat/recovery-delivery/phase2/review-fix-2/.r3-uat.webm
Risks and follow-up
The repaired request loses prior reasoning context and can lose prefix-cache hits; visible history is retained. Corrupt persisted reasoning is intentionally not rewritten, so later turns may incur rejected requests until compaction or new context. One in-stream repair does not disable the SDK's own retry budget.
Downgrade limitation: terminal reasoning-rejection handling is guaranteed only by versions containing this change. The parent version's history/abandon-marker readers accept the transient metadata, but its retry policy does not recognize
reasoning_rejectedand may resume its existing retry loop. This PR does not backport recovery into older binaries or add a persistence compatibility alias.Failed-request observability is a pre-existing, non-blocking limitation tracked in #4343, with redaction requirements and a follow-up trigger. No logging expansion is included here. Composer model selection on reload was also observed outside this diff; defect versus intended behavior was not established.
📋 Implementation Plan
Auto-heal the
Item with id 'rs_…' not foundretry loopResult
Yes — there is a small, automatic fix. The error is deterministic, so the retry loop can never succeed. The elegant fix is to stop sending the server-side reference at all: replay OpenAI reasoning as its self-contained
encrypted_contentblob (which Xum already persists) instead ofitem_reference: rs_…. That is a ~6-line, request-only change in the existing replay seam. It also repairs already-stuck workspaces on their next auto-retry, because every retry rebuilds the request fromchat.jsonlthrough that seam — no manual/compactneeded.A second, bounded layer (one-shot in-stream repair when OpenAI rejects a reasoning replay for any reason) is proposed as a stacked follow-up so a related cross-org failure cannot turn into the same loop.
What is happening (verified)
providerOptions.openai.{itemId: "rs_…", reasoningEncryptedContent}on reasoning parts only (src/node/services/streamManager.ts:4164-4271). Text and tool-call parts never carry anitemId, so this error class is confined to reasoning replay.attachReasoningReplayMetadata(src/node/utils/messages/reasoningProviderOptions.ts:130-176) bridges that metadata intoproviderMetadata. It drops a bareitemId(interrupted stream) but keepsitemIdwhenever encrypted content is present.storeis not set for OpenAI, so@ai-sdk/openaidefaultsstore: trueand — when anitemIdis present — emits{ type: "item_reference", id: "rs_…" }and ignores the encrypted content (node_modules/@ai-sdk/openai/dist/index.js:5676-5718). The request therefore depends on OpenAI's server-side item store.OPENAI_API_KEY; a Codex‑OAuthstore:falseturn followed by a direct turn), multi-instance gateways, or eviction. OpenAI returns HTTP 400Item with id 'rs_…' not found.StreamManager.categorizeErrormaps any other 400 to"api"(streamManager.ts:5406);"api"is not inNON_RETRYABLE_STREAM_ERRORS(src/common/utils/messages/retryEligibility.ts:49-60);RetryManagerhas no attempt cap (retryManager.ts:107-133,retryState.ts:53-67). Each retry re-reads history and rebuilds the identical request → infinite loop (attempt 11…). Sub-agent tasks loop the same way (taskService.ts:903-909only terminates on refusal/auth/quota/model/runtime errors)./compactfixed it only because the summary row carries no provider metadata and all later turns slice from the compaction boundary (compactionHandler.ts:986-1034,compactionBoundary.ts:157-176) — i.e. it threw away the context to get rid of one stale id.Why encrypted replay is safe and already supported
@ai-sdk/openaiCHANGELOG (4.0.0-beta.3): reasoning parts withoutitemIdare emitted as{ type: "reasoning", encrypted_content, summary }; "The OpenAI Responses API accepts reasoning items without anidwhenencrypted_contentis supplied". The converter path isindex.js:5719-5734and emits noidfield.include: ["reasoning.encrypted_content"]for every Responses reasoning model (src/common/utils/ai/providerOptions.ts:588-593), so the blob is present on every completed reasoning part.store:false,item_referencestripped inproviderModelFactory.ts:988-993) and Grok (store:falsedefault, 🤖 feat: default Grok Responses to store=false for ZDR parity #3807) already run every turn on inline encrypted reasoning.previous_response_idis never used (providerOptions.ts:485-488), so the existingretryStreamWithoutPreviousResponseIdone-shot repair (streamManager.ts:5223-5314) never fires for this path.invalid_encrypted_content— handled by Phase 2.Phase 1 — Make OpenAI reasoning replay self-contained (recommended; net ≈ +6 LoC product)
File:
src/node/utils/messages/reasoningProviderOptions.ts, insideattachReasoningReplayMetadataright after the existing bare-itemIddrop loop (lines 158-163).Notes for the implementer:
openaionly. Leavexaiuntouched (its converter behaviour without ids is not established; Grok already runsstore:false).chat.jsonlkeepsitemId(debuggability, downgrade safety). No migration.itemIdas required for replay (lines ~8, ~82, ~151-157) so the "why" stays accurate.storedefault change, no retry-policy change, no UI change.Effect on existing stuck workspaces: the next scheduled auto-retry (or startup auto-retry / user "continue") re-runs
streamWithHistory→prepareMessagesForProvider→ this seam → request succeeds. Nothing else needed.Tests (Phase 1)
src/node/utils/messages/reasoningProviderOptions.test.ts(describe("attachReasoningReplayMetadata")):openaiitemId + reasoningEncryptedContent→providerMetadata.openaiequals{ reasoningEncryptedContent }(noitemId); input part not mutated.xaiitemId + encrypted→ unchanged (pins the scope).itemIddrop case still passes.describeinsrc/node/services/providerModelFactory.test.ts, following thecapturedBodymock-fetchpattern at ~774-841, or a focused sibling test next tomessagePipeline.ts):{ itemId: "rs_stale", reasoningEncryptedContent: "blob" }+ assistant text, runprepareMessagesForProvider→createOpenAI({ fetch: mockFetch }).responses(...)doGenerate/streamText.store(unset) and explicitstore: true:body.inputcontains{ type: "reasoning", encrypted_content: "blob", summary: [...] }, contains noitem_reference, and the stringrs_staleappears nowhere. Assertstore: falseyields the same shape.transformModelMessagesmust still group them); summary text retained.prepareStep/tool-loop tests stay green (intra-turn SDK step messages are untouched by this change — they carry fresh same-route ids).Phase 2 — One-shot in-stream repair for rejected reasoning replay (stacked follow-up; net ≈ +80 LoC product)
Purpose: after Phase 1 the only remaining deterministic 400s from reasoning replay are (a)
invalid_encrypted_contentafter a cross-org route switch and (b)Item with id 'rs_…' not foundfrom intra-turn SDK step messages (fresh ids under a flapping gateway). Both would still hit the unbounded"api"retry loop. Mirror the existingretryStreamWithoutPreviousResponseIdpattern:src/node/services/streamManager.ts, next toextractPreviousResponseIdFromError):isOpenAIReasoningReplayRejection(error)— an OpenAI Responses request with HTTP 400/404 (or an SDK stream-error 500:StreamProviderError, orAPICallErrorwith an SSE content type and anerror/response.failedframe) and either an explicitItem with id 'rs_…' not foundmessage orerror.code === "invalid_encrypted_content"(also match "encrypted content" + "could not be verified"). ReuseextractErrorCode/extractStatusCode. Determine OpenAI Responses provenance from the resolved route/SDK model, not only the requested model prefix. Do not classify unrelatedrs_mentions, other item types, xAI, or non-Responses requests.src/node/utils/messages/reasoningProviderOptions.ts):stripOpenAIReasoningReplay(messages: ModelMessage[])— remove only reasoning parts whoseproviderOptions.openaiis set. Preserve string-content assistants, other-provider reasoning, all visible text and tool history. Drop only array-content assistant messages newly emptied by this removal. Do not mutate inputs; return the same array when nothing changed. SDK step messages retain the OpenAI namespace, so no broad reasoning removal or user-message coalescing is needed.retryStreamWithoutOpenAIReasoningReplay, sibling ofstreamManager.ts:5223-5314, wired at the call site ~4805-4824 with its owndidRetry…flag): same guards (not aborted, no soft interrupt,hasParts && currentStepStartIndex !== parts.length→ bail), apply the strip tostreamInfo.request.messages(andstepTracker.latestMessagesfor step scope),resetStreamStateForRetry,createStreamResult. Bail if the strip changed nothing. LogRetrying stream without OpenAI reasoning replaywitherrorCode/statusCode/retryScope.handleStreamFailure. A final error that still matches the narrow OpenAI Responses reasoning rejection isreasoning_rejected, including when repair was unsafe or a no-op. Add this value to the existingStreamErrorTypeSchema, sharedNON_RETRYABLE_STREAM_ERRORS, andRUNNING_TASK_TERMINAL_STREAM_ERRORS. This prevents the outer automatic retry loop and settles delegated workspace turns and running tasks. Preserve abort precedence. A subsequent 503, 401, or 429 retains its ordinary classification and retry/auth policy; merely attempting repair must not make other errors terminal. Manual continuation remains possible with a fresh attempt. No new persisted field, module, or subsystem.Accepted trade-off: the retried step/turn loses prior reasoning context (visible text/tool history is kept). No sticky flag — each turn may incur rejected requests until compaction/new context, including any SDK-internal retries. The single in-stream repair is invisible to the UI (no
stream-errorevent is emitted for that repair). Add a sticky flag only if telemetry shows it matters.Tests: mirror
describe("StreamManager - previousResponseId recovery")for both error shapes and one repair without an intermediate stream-error. Prove second rejection does not schedule an outer retry, delegated turns fail, running tasks settle, and persisted terminal errors do not auto-resume at startup. Cover normal policy after subsequent 503/401/429; abort, soft interrupt, emitted current-step parts, missing step messages, and no-op guards; preservation of prior-step text/tools/usage without re-execution; nonmutation and unchanged-array identity; negative matching and xAI; and manual continuation after a terminal attempt. Prefer existing fixtures and parameterized behavior tests over new harnesses.Rejected alternatives
store:falsefor OpenAI — changes dashboard/storage semantics for all users; unnecessary because only reasoning parts carry ids.Accepted trade-offs / residual risks
cachedInputTokens > 0on turn 2 in dogfooding.Acceptance criteria
make static-checkandbun test src/node/utils/messages src/node/services/providerModelFactory.test.ts src/node/services/streamManager.test.tsgreen.chat.jsonlreasoning part carries a bogusitemId(real encrypted content) continues successfully on a real OpenAI reasoning model without/compact; before the fix the same workspace loops withItem with id … not found.devtools.jsonl(API Debug Logs on) for turn 2+ shows"type":"reasoning"items withencrypted_contentand zeroitem_referenceentries.Dogfooding (screenshots and video; label as real-route evidence)
Use the
dev-server-sandboxskill (freshXUM_ROOT, free ports) andagent-browserwith an owned--session; startagent-browser record startbefore step 3,record stopafter step 6, andattach_filethe.webmplus each PNG.make dev-serverin the sandbox; enable API Debug Logs; select an OpenAI reasoning model available in this environment (coder:openai/gpt-5.xvia the gateway oropenai:gpt-5.xdirect). Screenshot: model picker.<XUM_ROOT>/sessions/<ws>/devtools.jsonlthat the requestinputcontainstype:"reasoning"+encrypted_contentand noitem_reference; recordcachedInputTokens. Screenshot: terminal grep output.chat.jsonland change the turn-1 reasoningproviderOptions.openai.itemIdtors_000stale; restart with the sameXUM_ROOT.mainbefore the change): send "continue" → observeStream Error [API] Item with id 'rs_000stale' not found+Retrying in …barrier. Screenshot.devtools.jsonlrequest line showing the blob and nors_000stale.devtools.jsonlfor the longest available history; report it in the PR."blob-broken") → send → expect one rejected OpenAI Responses request, oneRetrying stream without OpenAI reasoning replaylog line, and a successful repaired turn; noRetrying in …barrier or intermediate stream-error. Capture screenshots, video, request/log evidence, and unchanged persisted history. Automated integration tests separately prove that a second matching rejection terminates instead of starting the outer retry loop.Quality gates
make static-check+ targetedbun testgreen → dogfood steps 1–6 → PR (stacked base for Phase 2).Generated with
xum• Model:coder:openai/gpt-6-astra• Thinking:high• Cost:$194.25