fix(agents): unpack typed-block delta.content arrays in openai-completions stream#68418
fix(agents): unpack typed-block delta.content arrays in openai-completions stream#68418briandevans wants to merge 2 commits intoopenclaw:mainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
Fixes a bug in the OpenAI-completions streaming parser where some OpenAI-compatible providers (notably Mistral with reasoning enabled) stream choice.delta.content as an Anthropic-style typed-block array, which previously got coerced into "[object Object]" and leaked into user-visible text_delta output.
Changes:
- Unpack array-shaped
delta.contentinto separate text + thinking deltas and route them through the existing text/thinking streaming paths. - Add a unit test that reproduces #68309 and asserts no
"[object Object]"corruption in assembled text or streamedtext_deltaevents. - Add a changelog entry describing the bugfix and its impact.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| src/agents/openai-transport-stream.ts | Adds unpackOpenAICompletionsContent() and updates the completions streaming loop to correctly handle typed-block delta.content arrays (text + thinking). |
| src/agents/openai-transport-stream.test.ts | Adds a regression test covering mixed typed-block-array + string streaming chunks and asserts no object-coercion artifacts. |
| CHANGELOG.md | Documents the fix for Mistral/typed-block delta.content streaming corruption. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6a71aee605
ℹ️ 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".
|
|
Related work from PRtags group Title: Open PR duplicate: [Bug]: A problem with mistral small thinking enabled
|
|
Codex review: needs changes before merge. Summary Reproducibility: yes. The linked issue gives a concrete Mistral Small thinking scenario, and current main's direct content append explains the Next step before merge Security Review findings
Review detailsBest possible solution: Land one current-main parser fix that normalizes typed Do we have a high-confidence way to reproduce the issue? Yes. The linked issue gives a concrete Mistral Small thinking scenario, and current main's direct content append explains the Is this the best way to solve the issue? Unclear as submitted. Handling typed content in Full review comments:
Overall correctness: patch is incorrect Acceptance criteria:
What I checked:
Likely related people:
Remaining risk / open question:
Codex review notes: model gpt-5.5, reasoning high; reviewed against a6ccb5f698df. |
Summary
mistral/mistral-small-latestwith reasoning enabled streamsdelta.contentas an Anthropic-style typed-block array ([{type:"thinking", thinking:[…]}, {type:"text", text:"…"}]) instead of a string. The OpenAI-completions stream parser atsrc/agents/openai-transport-stream.ts:1121didcurrentBlock.text += choice.delta.content, which JS coerces to"" + [obj, obj]→ literal"[object Object][object Object]…"tokens that surfaced before the real assistant answer (and matched corruptedtext_deltaevents on the wire).[object Object]runs.unpackOpenAICompletionsContentnext togetCompletionsReasoningDelta. Plain stringdelta.contentkeeps the existing fast path. Arraydelta.contentis unpacked:{type:"text", text}blocks are concatenated into a real text delta and routed through the existing text-block append path;{type:"thinking", thinking}blocks (stringthinkingand nestedthinking:[{type:"text", text}]shape both supported) are routed through the existingappendThinkingDeltapath with the samependingThinkingDeltainterleaving guard the reasoning-field branch uses for tool-call chunks. Unknown truthy non-string shapes fall through to the reasoning/tool_calls branches instead of being coerced into assembled text.delta.contentkeeps byte-identical behavior — no behavior change for OpenAI/OpenRouter/etc. callers that already streamed strings. Reasoning-field handling (reasoning_content/reasoning/reasoning_text/reasoning_details) is unchanged. Tool-call deltas are unchanged. No new providers, model definitions, transport wrappers, or schema changes.Change Type (select all)
Scope (select all touched areas)
Linked Issue/PR
Root Cause (if applicable)
processOpenAICompletionsStreamassumedchoice.delta.contentwas always a string. Mistral-small-latest with reasoning enabled (and other OpenAI-compatible providers that proxy Anthropic-style content) emits it as a typed-block array, which JS string concatenation turned into literal[object Object]tokens in bothcurrentBlock.textand the streamedtext_deltaevent.text +=and no unpacker for the typed-block array shape that several reasoning-capable OpenAI-compat providers now emit.mistral-small-latestwithreasoning_effortenabled is the common trigger; the parser already handles other reasoning shapes (reasoning_detailswas added recently for OpenRouter/Qwen3) but didn't cover reasoning carried insidedelta.content.Regression Test Plan (if applicable)
src/agents/openai-transport-stream.test.ts— new caseunpacks Anthropic-style typed-block delta.content arrays without [object Object] coercion.delta.content = [{type:"thinking", thinking:[{type:"text", text:"Let me reason: "}, {type:"text", text:"step one."}]}], chunk 2 carries a mixed[{type:"thinking", thinking:" Step two."}, {type:"text", text:"Hello!"}]array, and chunk 3 carries a plain string" How can I help?"(verifying the legacy fast path still works). Asserts: assembled text equals"Hello! How can I help?", assembled thinking equals"Let me reason: step one. Step two.", and neither the assembled text nor any capturedtext_deltaevent contains the substring[object Object].text_deltaevents.handles reasoning_details from OpenRouter/Qwen3 in completions streamtests cover the top-level reasoning-field branch, not array-shapeddelta.content.mainwithout the fix:expected '[object Object][object Object],[object Object] How can I help?' not to contain '[object Object]'. Passes with the fix.User-visible / Behavior Changes
Mistral (and any other OpenAI-compatible provider that emits Anthropic-style typed-block
delta.content) reasoning replies now show the real model answer in the text channel and the reasoning content in the thinking channel, instead of[object Object]…prefix junk. No behavior change for providers that emit stringdelta.content.Diagram (if applicable)
Security Impact (required)
No)No)No)No)No)Repro + Verification
Environment
mistral/mistral-small-latestvia openai-completionsreasoning_effortenabledSteps
choice.delta.contentis an Anthropic-style typed-block array containing{type:"thinking", thinking:[…]}and{type:"text", text:"…"}blocks.output.content[].textand the emittedtext_deltaevents.Expected
[object Object]substrings in assembled text or in anytext_deltaevent.thinkingblock; user-visible text appears in atextblock.Actual
"[object Object][object Object],[object Object] How can I help?"and matchingtext_deltaevents carry the same junk."Hello! How can I help?"; thinking reads"Let me reason: step one. Step two.".Evidence
Stash the fix, keep the new test, run the scoped suite:
Re-apply the fix, rerun:
Human Verification (required)
node scripts/test-projects.mjs src/agents/openai-transport-stream.test.ts→ 56/56 pass with fix; the new case fails onmainwithout fix (reproduces the exact[object Object]prefix users see).node scripts/test-projects.mjs src/agents/pi-embedded-runner/openrouter-model-capabilities.test.ts src/agents/pi-embedded-runner/extra-params.openrouter-cache-control.test.ts(62 tests, all pass).node scripts/test-projects.mjs src/agents/simple-completion-runtime.selection.test.ts(6 tests, all pass).delta.contentpreserves byte-identical behavior (existing OpenRouter/Qwen3 reasoning + string content test still passes; covered explicitly in chunk 3 of the new test).delta.content = ""(empty string) keeps the original truthy gate — falls through to reasoning/tool_calls branches as before.text += "[object Object]").thinkingas a string andthinkingas a nested[{type:"text", text}]array.responseStream).delta.content(the unpacker is shape-driven, not provider-gated, so any provider that emits the same shape benefits).Review Conversations
Compatibility / Migration
Yes)No)No)Risks and Mitigations
typeother thantext/thinkingthat today silently coerces into[object Object]and that some downstream consumer accidentally pattern-matches on.unpacked.recognizedstaystruefor the array shape so the fall-through to reasoning/tool_calls is reserved for non-array, non-string shapes only. The pre-fix behavior for those rare shapes wastext += "[object Object]", which is strictly worse.delta.content = ""was previously falsy and skipped the content branch; this PR keeps the original truthy gate, so empty strings still skip and reasoning/tool_calls in the same chunk still process.if (choice.delta.content)retained verbatim.This PR was AI-assisted (Claude).