Skip to content

fix(agents): unpack typed-block delta.content arrays in openai-completions stream#68418

Open
briandevans wants to merge 2 commits intoopenclaw:mainfrom
briandevans:fix/openai-completions-array-content-coercion
Open

fix(agents): unpack typed-block delta.content arrays in openai-completions stream#68418
briandevans wants to merge 2 commits intoopenclaw:mainfrom
briandevans:fix/openai-completions-array-content-coercion

Conversation

@briandevans
Copy link
Copy Markdown
Contributor

Summary

  • Problem: On 2026.4.15, mistral/mistral-small-latest with reasoning enabled streams delta.content as an Anthropic-style typed-block array ([{type:"thinking", thinking:[…]}, {type:"text", text:"…"}]) instead of a string. The OpenAI-completions stream parser at src/agents/openai-transport-stream.ts:1121 did currentBlock.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 corrupted text_delta events on the wire).
  • Why it matters: Any OpenAI-compatible provider that emits typed-block content arrays (Mistral with reasoning_effort enabled is the immediate trigger; the same shape can appear from other providers that mirror Anthropic content blocks behind an OpenAI-completions endpoint) shipped corrupted output to the user. The thinking content was not surfaced at all (it lived inside the array blocks the parser threw away), and the visible answer was prefixed with junk [object Object] runs.
  • What changed: Added unpackOpenAICompletionsContent next to getCompletionsReasoningDelta. Plain string delta.content keeps the existing fast path. Array delta.content is 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 (string thinking and nested thinking:[{type:"text", text}] shape both supported) are routed through the existing appendThinkingDelta path with the same pendingThinkingDelta interleaving 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.
  • What did NOT change (scope boundary): String delta.content keeps 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)

  • Bug fix
  • Feature
  • Refactor required for the fix
  • Docs
  • Security hardening
  • Chore/infra

Scope (select all touched areas)

  • Gateway / orchestration
  • Skills / tool execution
  • Auth / tokens
  • Memory / storage
  • Integrations
  • API / contracts
  • UI / DX
  • CI/CD / infra

Linked Issue/PR

Root Cause (if applicable)

  • Root cause: processOpenAICompletionsStream assumed choice.delta.content was 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 both currentBlock.text and the streamed text_delta event.
  • Missing detection / guardrail: No type guard before text += and no unpacker for the typed-block array shape that several reasoning-capable OpenAI-compat providers now emit.
  • Contributing context (if known): Mistral's mistral-small-latest with reasoning_effort enabled is the common trigger; the parser already handles other reasoning shapes (reasoning_details was added recently for OpenRouter/Qwen3) but didn't cover reasoning carried inside delta.content.

Regression Test Plan (if applicable)

  • Coverage level that should have caught this:
    • Unit test
    • Seam / integration test
    • End-to-end test
    • Existing coverage already sufficient
  • Target test or file: src/agents/openai-transport-stream.test.ts — new case unpacks Anthropic-style typed-block delta.content arrays without [object Object] coercion.
  • Scenario the test should lock in: A 3-chunk Mistral-shaped stream where chunk 1 carries 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 captured text_delta event contains the substring [object Object].
  • Why this is the smallest reliable guardrail: The test exercises the exact failing parser path with the same chunk shape [Bug]: A problem with mistral small thinking enabled #68309 reports, including the fast-path mix, and pins both the assembled output and the on-the-wire text_delta events.
  • Existing test that already covers this (if any): None. Adjacent handles reasoning_details from OpenRouter/Qwen3 in completions stream tests cover the top-level reasoning-field branch, not array-shaped delta.content.
  • Verified the test fails on main without 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 string delta.content.

Diagram (if applicable)

Before (Mistral reasoning, delta.content = [thinking, text]):
chunk -> "" + [{type:thinking,…},{type:text,…}] -> currentBlock.text = "[object Object][object Object]…"

After:
chunk -> unpackOpenAICompletionsContent(content) -> { thinkingDelta, textDelta }
       -> appendThinkingDelta(thinkingDelta) (unchanged path)
       -> currentBlock.text += textDelta       (unchanged path)

Security Impact (required)

  • New permissions/capabilities? (No)
  • Secrets/tokens handling changed? (No)
  • New/changed network calls? (No)
  • Command/tool execution surface changed? (No)
  • Data access scope changed? (No)

Repro + Verification

Environment

  • OS: macOS (issue reporter Ubuntu 24.04)
  • Runtime/container: local dev tree
  • Model/provider: mistral/mistral-small-latest via openai-completions
  • Integration/channel (if any): assistant text reply path
  • Relevant config (redacted): mistral provider with reasoning_effort enabled

Steps

  1. Stream chunks where choice.delta.content is an Anthropic-style typed-block array containing {type:"thinking", thinking:[…]} and {type:"text", text:"…"} blocks.
  2. Observe assembled output.content[].text and the emitted text_delta events.

Expected

  • No [object Object] substrings in assembled text or in any text_delta event.
  • Thinking text appears in a thinking block; user-visible text appears in a text block.

Actual

  • Without the fix: assembled text reads "[object Object][object Object],[object Object] How can I help?" and matching text_delta events carry the same junk.
  • With the fix: assembled text reads "Hello! How can I help?"; thinking reads "Let me reason: step one. Step two.".

Evidence

  • Failing test/log before + passing after

Stash the fix, keep the new test, run the scoped suite:

node scripts/test-projects.mjs src/agents/openai-transport-stream.test.ts
…
× unpacks Anthropic-style typed-block delta.content arrays without [object Object] coercion
   AssertionError: expected '[object Object][object Object],[object Object] How can I help?' not to contain '[object Object]'

Re-apply the fix, rerun:

node scripts/test-projects.mjs src/agents/openai-transport-stream.test.ts
 Test Files  1 passed (1)
      Tests  56 passed (56)

Human Verification (required)

  • Verified scenarios:
    • Targeted suite node scripts/test-projects.mjs src/agents/openai-transport-stream.test.ts → 56/56 pass with fix; the new case fails on main without fix (reproduces the exact [object Object] prefix users see).
    • Adjacent OpenRouter completions tests still green: 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).
    • Adjacent simple-completion runtime selection: node scripts/test-projects.mjs src/agents/simple-completion-runtime.selection.test.ts (6 tests, all pass).
  • Edge cases checked:
    • Plain string delta.content preserves 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.
    • Truthy unknown non-string non-array shapes fall through to the reasoning / tool_calls branches instead of being coerced to assembled text (regression that previously masked into text += "[object Object]").
    • Mistral two thinking shapes covered: thinking as a string and thinking as a nested [{type:"text", text}] array.
  • What I did not verify:
    • End-to-end live Mistral API call (no Mistral API key available locally; reproduced the exact byte-level shape via mocked responseStream).
    • Other OpenAI-compatible providers that may emit typed-block delta.content (the unpacker is shape-driven, not provider-gated, so any provider that emits the same shape benefits).

Review Conversations

  • I replied to or resolved every bot review conversation I addressed in this PR.
  • I left unresolved only the conversations that still need reviewer or maintainer judgment.

Compatibility / Migration

  • Backward compatible? (Yes)
  • Config/env changes? (No)
  • Migration needed? (No)

Risks and Mitigations

  • Risk: Some OpenAI-compatible provider could emit a typed block with type other than text/thinking that today silently coerces into [object Object] and that some downstream consumer accidentally pattern-matches on.
    • Mitigation: unrecognized block types are skipped (not coerced); unpacked.recognized stays true for 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 was text += "[object Object]", which is strictly worse.
  • Risk: 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.
    • Mitigation: if (choice.delta.content) retained verbatim.

This PR was AI-assisted (Claude).

Copilot AI review requested due to automatic review settings April 18, 2026 05:35
@openclaw-barnacle openclaw-barnacle Bot added agents Agent runtime and tooling size: M labels Apr 18, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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.content into 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 streamed text_delta events.
  • 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.

Comment thread src/agents/openai-transport-stream.ts Outdated
Comment thread src/agents/openai-transport-stream.ts
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

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: 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".

Comment thread src/agents/openai-transport-stream.ts Outdated
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 18, 2026

Greptile Summary

This PR fixes a bug where Mistral (and other OpenAI-compatible providers) streaming delta.content as an Anthropic-style typed-block array ([{type:"thinking",...},{type:"text",...}]) was coerced via JS string concatenation to "[object Object]" tokens in assembled text and streamed text_delta events. The fix adds unpackOpenAICompletionsContent to detect and route array-shaped content to the correct thinking/text paths, while keeping string delta.content on the existing fast path byte-for-byte identical. A targeted regression test is included that verifies both the corrupted-before/correct-after behavior and the legacy fast path.

Confidence Score: 5/5

Safe to merge — targeted bug fix with correct logic, no behavior change for existing string delta.content callers, and a regression test that fails without the fix.

All findings are P2 or lower. The new unpackOpenAICompletionsContent helper is correctly typed, handles edge cases (empty array, null, unrecognized shapes), and mirrors the existing reasoning-field thinking path exactly. The 'content_thinking' signature follows the same plain-string pattern already used by other completions-path reasoning signatures and does not reach any JSON.parse surface. String fast path is byte-identical.

No files require special attention.

Reviews (1): Last reviewed commit: "fix(agents): unpack typed-block delta.co..." | Re-trigger Greptile

@prtags
Copy link
Copy Markdown

prtags Bot commented Apr 23, 2026

Related work from PRtags group golden-werewolf-3vn3

Title: Open PR duplicate: [Bug]: A problem with mistral small thinking enabled

Number Title
#68418* fix(agents): unpack typed-block delta.content arrays in openai-completions stream
#68855 fix: guard against non-string content delta and thinking blocks

* This PR

@clawsweeper
Copy link
Copy Markdown
Contributor

clawsweeper Bot commented Apr 30, 2026

Codex review: needs changes before merge.

Summary
The PR adds OpenAI-completions stream handling for array-shaped typed delta.content blocks, regression tests for Mistral-style thinking/text chunks, and a changelog entry.

Reproducibility: yes. The linked issue gives a concrete Mistral Small thinking scenario, and current main's direct content append explains the [object Object] output; the PR's mocked stream reproduces the byte-level typed-block shape without live credentials.

Next step before merge
The remaining blockers are narrow enough for an automated repair: rebase/adapt the parser fix to current main, preserve fallthrough when no non-empty typed delta is emitted, and add the required changelog attribution.

Security
Cleared: The diff only changes stream parsing, tests, and changelog text; it adds no dependencies, CI execution, permissions, network calls, or secret handling.

Review findings

  • [P2] Fall through when typed blocks produce no deltas — src/agents/openai-transport-stream.ts:1272
  • [P2] Add the contributor attribution to the changelog — CHANGELOG.md:51
Review details

Best possible solution:

Land one current-main parser fix that normalizes typed delta.content arrays generically, preserves same-chunk reasoning/tool-call processing when no text/thinking delta is emitted, and keeps focused tests plus a correctly attributed changelog entry.

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 [object Object] output; the PR's mocked stream reproduces the byte-level typed-block shape without live credentials.

Is this the best way to solve the issue?

Unclear as submitted. Handling typed content in src/agents/openai-transport-stream.ts is the right boundary, but the branch needs the empty-delta fallthrough fix, changelog attribution, and adaptation to current main before it is the best merge candidate.

Full review comments:

  • [P2] Fall through when typed blocks produce no deltas — src/agents/openai-transport-stream.ts:1272
    sawSupportedBlock is set even when a supported text or thinking block contains only an empty string, so recognized becomes true and the caller continues without processing reasoning_* fields or tool_calls in the same chunk. Mark the content as consumed only after at least one non-empty delta is emitted, or otherwise fall through when deltas is empty.
    Confidence: 0.82
  • [P2] Add the contributor attribution to the changelog — CHANGELOG.md:51
    This user-facing fix adds a changelog entry, but OpenClaw changelog policy requires contributor-authored fix entries to include a Thanks @... attribution. Add Thanks @briandevans. to the new bullet before merge.
    Confidence: 0.95

Overall correctness: patch is incorrect
Overall confidence: 0.84

Acceptance criteria:

  • pnpm test src/agents/openai-transport-stream.test.ts
  • pnpm exec oxfmt --check --threads=1 src/agents/openai-transport-stream.ts src/agents/openai-transport-stream.test.ts CHANGELOG.md
  • pnpm check:changed

What I checked:

  • Current-main parser still appends raw content: On current main, truthy choice.delta.content is sent directly to appendTextDelta, whose internal text append expects a string; an array payload would still be concatenated into text. (src/agents/openai-transport-stream.ts:1460, d961235a8916)
  • Mistral thinking trigger is enabled: The bundled Mistral compat marks mistral-small-latest as supporting reasoning effort, matching the linked bug's reported trigger surface. (extensions/mistral/api.ts:42, d961235a8916)
  • PR targets the implicated stream branch: The PR replaces the raw content append path with unpackOpenAICompletionsContent and routes recognized typed-block text/thinking deltas separately. (src/agents/openai-transport-stream.ts:1121, 06b26221735b)
  • PR includes focused regression coverage: The PR adds mocked Mistral-style typed-block stream tests for no [object Object] leakage, block ordering, and unsupported-array fallthrough. (src/agents/openai-transport-stream.test.ts:2021, 06b26221735b)
  • Remaining parser bug in PR: sawSupportedBlock is set for supported typed blocks even when they emit no non-empty delta, so the caller treats the chunk as consumed and skips same-chunk reasoning/tool-call handling. (src/agents/openai-transport-stream.ts:1272, 06b26221735b)
  • Required changelog attribution missing: The new user-facing fix entry does not include Thanks @briandevans., which OpenClaw changelog policy requires for contributor-authored fix entries. (CHANGELOG.md:51, 06b26221735b)

Likely related people:

  • @vincentkoc: Authored merged OpenRouter reasoning_details work that refactored the same OpenAI-completions stream reasoning/text/tool-call path. (role: recent adjacent owner; confidence: high; commits: 68502c90d1fb; files: src/agents/openai-transport-stream.ts, src/agents/openai-transport-stream.test.ts)
  • @bladin: Authored the merged Qwen3/OpenRouter reasoning_details stream handling and tests in the same parser area. (role: introduced adjacent behavior; confidence: high; commits: e0bf756b50b5; files: src/agents/openai-transport-stream.ts, src/agents/openai-transport-stream.test.ts)
  • @obviyus: Co-authored adjacent reasoning-details work in the same stream parser path according to commit metadata and prior ClawSweeper routing context. (role: adjacent maintainer; confidence: medium; commits: e0bf756b50b5; files: src/agents/openai-transport-stream.ts, src/agents/openai-transport-stream.test.ts)
  • @neeravmakwana: Authored the Mistral reasoning_effort support for mistral-small-latest, which made the reported Mistral thinking stream path reachable. (role: introduced trigger surface; confidence: medium; commits: 68bfc6fcf55a; files: extensions/mistral/api.ts, extensions/mistral/model-definitions.ts, docs/providers/mistral.md)

Remaining risk / open question:

  • The PR branch is based on an older parser shape; current main has newer post-tool-call delta buffering and plural reasoning-delta handling, so the fix needs rebase/adaptation before merge.
  • A newer open PR, fix(agents): normalize structured delta.content blocks to prevent [object Object] in chat replies #75339, covers a closely related structured-content normalization path; the repair lane should avoid creating competing final fixes.
  • The exact Mistral payload is covered by mocked stream evidence rather than a live Mistral API trace, although the source failure mode is concrete.

Codex review notes: model gpt-5.5, reasoning high; reviewed against a6ccb5f698df.

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

Labels

agents Agent runtime and tooling size: M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: A problem with mistral small thinking enabled

2 participants