fix(responses): drop orphaned assistant msg_* id when reasoning is dropped (#88019)#88067
Conversation
|
Codex review: needs maintainer review before merge. Reviewed May 30, 2026, 3:56 PM ET / 19:56 UTC. Summary PR surface: Source +78, Tests +265. Total +343 across 6 files. Reproducibility: yes. Source inspection shows current main drops replayable reasoning on model change while retaining textSignature message ids, and the PR body supplies redacted live Azure before/after output for the rejected and accepted replay shapes. Review metrics: 1 noteworthy metric.
Merge readiness Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch. Rank-up moves:
Risk before merge
Maintainer options:
Next step before merge
Security Review detailsBest possible solution: Land this targeted sanitizer and conversion fix after maintainer review, then rebase, narrow, or close the overlapping stale-id replay PR so OpenClaw keeps one canonical Responses replay policy. Do we have a high-confidence way to reproduce the issue? Yes. Source inspection shows current main drops replayable reasoning on model change while retaining textSignature message ids, and the PR body supplies redacted live Azure before/after output for the rejected and accepted replay shapes. Is this the best way to solve the issue? Yes. The sanitizer is the narrow boundary that knows a prior model snapshot caused reasoning to be dropped, and the PR also updates both Responses conversion paths so phase-only/id-less replay remains valid. AGENTS.md: found and applied where relevant. Codex review notes: model gpt-5.5, reasoning high; reviewed against eb5e80f58a69. Label changesLabel changes:
Label justifications:
Evidence reviewedPR surface: Source +78, Tests +265. Total +343 across 6 files. View PR surface stats
What I checked:
Likely related people:
What the crustacean ranks mean
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics. How this review workflow works
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4da0deaf5b
ℹ️ 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".
| if (contentBlock.type === "text" && contentBlock.textSignature !== undefined) { | ||
| const { textSignature: _droppedTextSignature, ...rest } = contentBlock; | ||
| return rest; |
There was a problem hiding this comment.
Preserve phase metadata when stripping response item ids
When dropReplayableReasoning handles a model/fallback switch, this removes the whole textSignature, but that field also carries the Responses phase (commentary/final_answer) that convertResponsesMessages replays and resolveAssistantMessagePhase uses to keep commentary out of user-visible output. For any prior Responses turn with reasoning plus phased commentary/final text blocks, the sanitizer now stores those blocks with neither the orphaned msg_* id nor the phase, so the next Responses request replays commentary as an unphased assistant message instead of preserving the existing phase contract. Strip only the id or otherwise retain the phase metadata while removing the paired item id.
Useful? React with 👍 / 👎.
| // turn (issue #88019). Strip the signature so the id and reasoning go together. | ||
| const finalContent = droppedReplayableReasoning | ||
| ? nextContent.map((contentBlock) => { | ||
| if (contentBlock.type === "text" && contentBlock.textSignature !== undefined) { |
There was a problem hiding this comment.
Guard malformed blocks during model-switch cleanup
If a persisted assistant turn contains a malformed null/primitive content block, the loop above explicitly preserves it, but after a replayable reasoning item is dropped this new map dereferences contentBlock.type unconditionally. That means a corrupted transcript that previously sanitized through sanitizeSessionHistory can now throw during a Responses model switch instead of recovering the session. Check that contentBlock is an object before reading type.
Useful? React with 👍 / 👎.
…opped (openclaw#88019) When an Azure/OpenAI Responses session falls back to a non-Responses model and later resumes a Responses model, sanitizeSessionHistory drops the replayable reasoning (rs_*) item via downgradeOpenAIReasoningBlocks. The paired assistant text block still carried its textSignature (the msg_* id), so the transport replayed an assistant message item referencing msg_* with no accompanying rs_* reasoning item. Azure Responses then rejected the next turn with: 400 Item 'msg_...' provided without its required 'reasoning' item: 'rs_...' permanently poisoning the session. Fix: - downgradeOpenAIReasoningBlocks now strips the textSignature from a turn's text blocks whenever it drops a replayable reasoning item, so the msg_* id and its rs_* reasoning are removed together. The transport then falls back to a synthetic, unpaired id that Azure accepts. - Because the synthetic fallback id is derived from the per-message msgIndex, multiple id-less text blocks in one assistant turn (e.g. commentary + final_answer) would collide on the same id. Make the fallback unique per text block in both Responses conversion sites (openai-transport-stream.ts and the shared llm provider openai-responses-shared.ts). Tests: - sanitize-session-history: model-switch path drops the paired msg_* id. - embedded-agent-helpers: downgrade strips paired text signature(s). - reasoning-replay: multiple id-less text blocks get distinct item ids. Co-authored-by: Copilot <[email protected]>
…penclaw#88019) Address PR review feedback on the orphaned msg_* replay fix: - Preserve Responses phase metadata: dropping the paired msg_* id when its rs_* reasoning is removed previously stripped the entire textSignature, which also discarded the phase (commentary/final_answer). Phased text now keeps a phase-only signature ({v:1,phase}) so commentary is not replayed as user-visible output. Both parseTextSignature copies (shared provider and embedded transport) now accept id-less phase-only signatures and fall back to a synthetic id while preserving the phase. - Guard malformed content blocks: the post-drop map no longer dereferences contentBlock.type unconditionally, so a corrupted transcript with a null/primitive block can still sanitize through a model switch. Tests: - sanitize-session-history: phase metadata is preserved while the paired id is dropped on a model switch. - reasoning-replay: id-less phase-only signatures get distinct synthetic ids and retain their phase. Co-authored-by: Copilot <[email protected]>
4da0dea to
89c98bf
Compare
|
Addressed the review feedback in the latest push (rebased onto current
New regression tests cover both: phase metadata preserved while the paired id is dropped, and id-less phase-only signatures getting distinct synthetic ids while retaining their phase. All five acceptance test files pass; lint and the plugin-sdk dts boundary typecheck are clean. The earlier Real-behavior proof from a live Azure/OpenAI Responses fallback→return→next-turn recovery is being prepared and will be added to the PR body. |
|
Added a Real behavior proof section to the PR description: a live Azure OpenAI Responses run (model
@clawsweeper re-review please. |
|
🦞🧹 I asked ClawSweeper to review this item again. |
|
Verification before merge: Behavior addressed: Azure/OpenAI Responses replay no longer keeps a reasoning-paired assistant msg_* id after the paired rs_* reasoning item is dropped on a persisted model-snapshot change. Real environment tested: contributor supplied live Azure OpenAI Responses proof for gpt-5.5 in the PR body. The Real behavior proof check is green: https://github.com/openclaw/openclaw/actions/runs/26693459846/job/78673824930 Exact steps or command run after this patch:
Evidence after fix: autoreview returned clean with no accepted/actionable findings and specifically confirmed msg_* id removal is gated by dropReplayableReasoning, which sanitizeSessionHistory only enables when the stored model snapshot differs. CI is green, including build/check/lint/type/test shards and Real behavior proof. Observed result after fix: model-switch replay drops orphaned Responses msg_* ids while preserving commentary/final_answer phase metadata and generating distinct synthetic fallback ids for id-less text blocks. What was not tested: I did not rerun the full local Vitest bundle; relied on the contributor's targeted tests, green CI, the live Azure proof, git diff --check, and autoreview. |
steipete
left a comment
There was a problem hiding this comment.
Approved after maintainer review.
The reviewed blast radius is Responses-family replay after a persisted provider/API/model snapshot change. The orphan msg_* id stripping is gated by dropReplayableReasoning, which sanitizeSessionHistory only enables when the stored model snapshot differs. Autoreview returned clean with no accepted/actionable findings, CI is green, and the PR includes live Azure proof for the original 400-to-200 replay behavior.
…opped (openclaw#88019) (openclaw#88067) * fix(responses): drop orphaned assistant msg_* id when reasoning is dropped (openclaw#88019) When an Azure/OpenAI Responses session falls back to a non-Responses model and later resumes a Responses model, sanitizeSessionHistory drops the replayable reasoning (rs_*) item via downgradeOpenAIReasoningBlocks. The paired assistant text block still carried its textSignature (the msg_* id), so the transport replayed an assistant message item referencing msg_* with no accompanying rs_* reasoning item. Azure Responses then rejected the next turn with: 400 Item 'msg_...' provided without its required 'reasoning' item: 'rs_...' permanently poisoning the session. Fix: - downgradeOpenAIReasoningBlocks now strips the textSignature from a turn's text blocks whenever it drops a replayable reasoning item, so the msg_* id and its rs_* reasoning are removed together. The transport then falls back to a synthetic, unpaired id that Azure accepts. - Because the synthetic fallback id is derived from the per-message msgIndex, multiple id-less text blocks in one assistant turn (e.g. commentary + final_answer) would collide on the same id. Make the fallback unique per text block in both Responses conversion sites (openai-transport-stream.ts and the shared llm provider openai-responses-shared.ts). Tests: - sanitize-session-history: model-switch path drops the paired msg_* id. - embedded-agent-helpers: downgrade strips paired text signature(s). - reasoning-replay: multiple id-less text blocks get distinct item ids. Co-authored-by: Copilot <[email protected]> * fix(responses): preserve phase metadata and guard malformed blocks (openclaw#88019) Address PR review feedback on the orphaned msg_* replay fix: - Preserve Responses phase metadata: dropping the paired msg_* id when its rs_* reasoning is removed previously stripped the entire textSignature, which also discarded the phase (commentary/final_answer). Phased text now keeps a phase-only signature ({v:1,phase}) so commentary is not replayed as user-visible output. Both parseTextSignature copies (shared provider and embedded transport) now accept id-less phase-only signatures and fall back to a synthetic id while preserving the phase. - Guard malformed content blocks: the post-drop map no longer dereferences contentBlock.type unconditionally, so a corrupted transcript with a null/primitive block can still sanitize through a model switch. Tests: - sanitize-session-history: phase metadata is preserved while the paired id is dropped on a model switch. - reasoning-replay: id-less phase-only signatures get distinct synthetic ids and retain their phase. Co-authored-by: Copilot <[email protected]> --------- Co-authored-by: Copilot <[email protected]>
…opped (openclaw#88019) (openclaw#88067) * fix(responses): drop orphaned assistant msg_* id when reasoning is dropped (openclaw#88019) When an Azure/OpenAI Responses session falls back to a non-Responses model and later resumes a Responses model, sanitizeSessionHistory drops the replayable reasoning (rs_*) item via downgradeOpenAIReasoningBlocks. The paired assistant text block still carried its textSignature (the msg_* id), so the transport replayed an assistant message item referencing msg_* with no accompanying rs_* reasoning item. Azure Responses then rejected the next turn with: 400 Item 'msg_...' provided without its required 'reasoning' item: 'rs_...' permanently poisoning the session. Fix: - downgradeOpenAIReasoningBlocks now strips the textSignature from a turn's text blocks whenever it drops a replayable reasoning item, so the msg_* id and its rs_* reasoning are removed together. The transport then falls back to a synthetic, unpaired id that Azure accepts. - Because the synthetic fallback id is derived from the per-message msgIndex, multiple id-less text blocks in one assistant turn (e.g. commentary + final_answer) would collide on the same id. Make the fallback unique per text block in both Responses conversion sites (openai-transport-stream.ts and the shared llm provider openai-responses-shared.ts). Tests: - sanitize-session-history: model-switch path drops the paired msg_* id. - embedded-agent-helpers: downgrade strips paired text signature(s). - reasoning-replay: multiple id-less text blocks get distinct item ids. Co-authored-by: Copilot <[email protected]> * fix(responses): preserve phase metadata and guard malformed blocks (openclaw#88019) Address PR review feedback on the orphaned msg_* replay fix: - Preserve Responses phase metadata: dropping the paired msg_* id when its rs_* reasoning is removed previously stripped the entire textSignature, which also discarded the phase (commentary/final_answer). Phased text now keeps a phase-only signature ({v:1,phase}) so commentary is not replayed as user-visible output. Both parseTextSignature copies (shared provider and embedded transport) now accept id-less phase-only signatures and fall back to a synthetic id while preserving the phase. - Guard malformed content blocks: the post-drop map no longer dereferences contentBlock.type unconditionally, so a corrupted transcript with a null/primitive block can still sanitize through a model switch. Tests: - sanitize-session-history: phase metadata is preserved while the paired id is dropped on a model switch. - reasoning-replay: id-less phase-only signatures get distinct synthetic ids and retain their phase. Co-authored-by: Copilot <[email protected]> --------- Co-authored-by: Copilot <[email protected]>
…opped (openclaw#88019) (openclaw#88067) * fix(responses): drop orphaned assistant msg_* id when reasoning is dropped (openclaw#88019) When an Azure/OpenAI Responses session falls back to a non-Responses model and later resumes a Responses model, sanitizeSessionHistory drops the replayable reasoning (rs_*) item via downgradeOpenAIReasoningBlocks. The paired assistant text block still carried its textSignature (the msg_* id), so the transport replayed an assistant message item referencing msg_* with no accompanying rs_* reasoning item. Azure Responses then rejected the next turn with: 400 Item 'msg_...' provided without its required 'reasoning' item: 'rs_...' permanently poisoning the session. Fix: - downgradeOpenAIReasoningBlocks now strips the textSignature from a turn's text blocks whenever it drops a replayable reasoning item, so the msg_* id and its rs_* reasoning are removed together. The transport then falls back to a synthetic, unpaired id that Azure accepts. - Because the synthetic fallback id is derived from the per-message msgIndex, multiple id-less text blocks in one assistant turn (e.g. commentary + final_answer) would collide on the same id. Make the fallback unique per text block in both Responses conversion sites (openai-transport-stream.ts and the shared llm provider openai-responses-shared.ts). Tests: - sanitize-session-history: model-switch path drops the paired msg_* id. - embedded-agent-helpers: downgrade strips paired text signature(s). - reasoning-replay: multiple id-less text blocks get distinct item ids. Co-authored-by: Copilot <[email protected]> * fix(responses): preserve phase metadata and guard malformed blocks (openclaw#88019) Address PR review feedback on the orphaned msg_* replay fix: - Preserve Responses phase metadata: dropping the paired msg_* id when its rs_* reasoning is removed previously stripped the entire textSignature, which also discarded the phase (commentary/final_answer). Phased text now keeps a phase-only signature ({v:1,phase}) so commentary is not replayed as user-visible output. Both parseTextSignature copies (shared provider and embedded transport) now accept id-less phase-only signatures and fall back to a synthetic id while preserving the phase. - Guard malformed content blocks: the post-drop map no longer dereferences contentBlock.type unconditionally, so a corrupted transcript with a null/primitive block can still sanitize through a model switch. Tests: - sanitize-session-history: phase metadata is preserved while the paired id is dropped on a model switch. - reasoning-replay: id-less phase-only signatures get distinct synthetic ids and retain their phase. Co-authored-by: Copilot <[email protected]> --------- Co-authored-by: Copilot <[email protected]>
Summary
Fixes #88019.
When an Azure/OpenAI Responses session falls back to a non-Responses model and later resumes a Responses model,
sanitizeSessionHistorydrops the replayable reasoning (rs_*) item viadowngradeOpenAIReasoningBlocks. However, the paired assistant text block kept itstextSignature(which carries themsg_*id), so the transport replayed an assistant message item referencingmsg_*without its requiredrs_*reasoning item. Azure Responses then rejected the next turn with:permanently poisoning the session.
The transport's
isDifferentModelguard cannot catch this: after returning to the original Responses model,msg.model === model.idagain, so only the sanitizer (which compares against the prior model snapshot) knows the reasoning was dropped.Changes
downgradeOpenAIReasoningBlocks(src/agents/embedded-agent-helpers/openai.ts): when it drops a replayable reasoning item from an assistant turn, it now also strips thetextSignaturefrom that turn's text blocks, so themsg_*id and itsrs_*reasoning are removed together. The conversion layer then falls back to a synthetic, unpaired id that Azure accepts. Absence of the signature is robust through the rest of the sanitize pipeline.msgIndex, so multiple id-less text blocks in one assistant turn (e.g.commentary+final_answer) would collide on the same id. The fallback is now made unique per text block in both Responses conversion sites:src/llm/providers/openai-responses-shared.ts(shared provider path used by openai/azure/codex Responses)src/agents/openai-transport-stream.ts(embedded agent transport)Tests
embedded-agent-runner.sanitize-session-history.test.ts— model-switch path drops the pairedmsg_*id.embedded-agent-helpers.sanitizeuserfacingtext.test.ts— downgrade strips paired text signature(s), including the multi-text-block case.openai-responses.reasoning-replay.test.ts— multiple id-less text blocks get distinct item ids.All acceptance tests from the review pass:
oxlint and the plugin-sdk dts boundary typecheck are clean for the changed files.
Real behavior proof
Behavior or issue addressed: After an Azure/OpenAI Responses session (
gpt-5.5) falls back to a non-Responses model and then resumes, openclaw replayed an assistantmessageitem carrying the reasoning-pairedmsg_*id without itsrs_*reasoning item, so Azure rejected every following turn with HTTP 400Item 'msg_...' ... was provided without its required 'reasoning' item: 'rs_...', permanently poisoning the session. This patch strips the orphanmsg_*id while preserving the{v:1,phase}metadata.Real environment tested: The reporter's live Azure OpenAI Responses deployment, model
gpt-5.5(endpoint and ids redacted). A standalone Node script calls the real/openai/v1/responsesAPI directly; the running gateway and chat channels were not touched.Exact steps or command run after this patch:
node proof.mjsagainst the live Azure endpoint — (1) a reasoning turn captures a realrs_*/msg_*pair; (2) replay the assistantmsg_*id without itsrs_*reasoning item, i.e. the pre-patch behavior; (3) replay the same turn with the orphanmsg_*id stripped and{v:1,phase}preserved, i.e. the patched behavior.Evidence after fix:
Observed result after fix: STEP 2 (orphan
msg_*id replayed) returns live Azure HTTP 400 with the issue's verbatim error. STEP 3 (orphan id stripped — the patched behavior) returns live Azure HTTP 200 and the conversation continues (assistant replied: "DONE"). STEP 1 also shows the realrs_*andmsg_*ids share a correlation prefix and the message carriesphase: "final_answer", confirming the id is reasoning-coupled and thatphasemust be preserved.What was not tested: Non-Azure Responses providers (OpenAI.com, codex) were not exercised against a live endpoint; they share the same conversion path covered by the regression suite.