fix(agents): normalize structured delta.content blocks to prevent [object Object] in chat replies#75339
Conversation
|
Codex review: needs real behavior proof before merge. Summary Reproducibility: yes. there is a high-confidence source-level reproduction path: current main passes non-string Real behavior proof Next step before merge Security Review findings
Review detailsBest possible solution: Land one reconciled shared OpenAI-compatible stream parser fix that preserves sibling string fallbacks after unsupported nested structures, covers Do we have a high-confidence way to reproduce the issue? Yes, there is a high-confidence source-level reproduction path: current main passes non-string Is this the best way to solve the issue? No for the current PR revision. Normalizing at the shared OpenAI-compatible stream parser is the right boundary, but this branch should preserve fallback string fields after unsupported nested values and include after-fix real behavior proof before merge. 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 5fae1c32b5f8. |
…gelog credit Round-1 review on PR openclaw#75339 caught two gaps: 1. Nested thinking arrays were dropped. The original normalizer only read `record.thinking` as a string, but the Mistral typed-block reproduction includes `type:"thinking"` blocks whose `thinking` value is itself an array of `{type:"text", text:"..."}` parts. Recurse into nested text/thinking/content arrays-and-objects, then re-tag the resulting parts as thinking when the outer block declared a thinking-style type (`thinking`, `reasoning`, or `reasoning.text`). Same recursion is safe for nested `text` and `content` shapes — they fall through as text deltas. 2. Missing CHANGELOG entry. Add an Unreleased `### Fixes` bullet with contributor credit per the repo policy in CLAUDE.md ("every added entry must include at least one Thanks @author attribution"). Add 4 round-2 regression tests: - type:thinking block with thinking: [array of text parts] flattens to per-part thinking deltas keyed on the outer signature - type:reasoning block with text: [array of text parts] flattens to per-part thinking deltas - untyped block with content: [array of text parts] flattens to text - nested thinking block with only empty sub-blocks returns no parts (the "never emit [object Object]" invariant still holds for the recursive case) Refs openclaw#75268, openclaw#70806
|
Round-1 review addressed in 8248b3e. Both P2 findings closed. P2: nested thinking arraysThe bot was right — the original normalizer only read { "type": "thinking", "thinking": [ {"type":"text","text":"step one"}, {"type":"text","text":"step two"} ] }silently produced zero parts and the user lost the reasoning content entirely. Fixed by recursing into nested P2: missing CHANGELOGAdded an Unreleased New regression tests (4)
Acceptance criteria checked locally
PTAL. |
…gelog credit Round-1 review on PR openclaw#75339 caught two gaps: 1. Nested thinking arrays were dropped. The original normalizer only read `record.thinking` as a string, but the Mistral typed-block reproduction includes `type:"thinking"` blocks whose `thinking` value is itself an array of `{type:"text", text:"..."}` parts. Recurse into nested text/thinking/content arrays-and-objects, then re-tag the resulting parts as thinking when the outer block declared a thinking-style type (`thinking`, `reasoning`, or `reasoning.text`). Same recursion is safe for nested `text` and `content` shapes — they fall through as text deltas. 2. Missing CHANGELOG entry. Add an Unreleased `### Fixes` bullet with contributor credit per the repo policy in CLAUDE.md ("every added entry must include at least one Thanks @author attribution"). Add 4 round-2 regression tests: - type:thinking block with thinking: [array of text parts] flattens to per-part thinking deltas keyed on the outer signature - type:reasoning block with text: [array of text parts] flattens to per-part thinking deltas - untyped block with content: [array of text parts] flattens to text - nested thinking block with only empty sub-blocks returns no parts (the "never emit [object Object]" invariant still holds for the recursive case) Refs openclaw#75268, openclaw#70806
8248b3e to
8723d35
Compare
…gelog credit Round-1 review on PR openclaw#75339 caught two gaps: 1. Nested thinking arrays were dropped. The original normalizer only read `record.thinking` as a string, but the Mistral typed-block reproduction includes `type:"thinking"` blocks whose `thinking` value is itself an array of `{type:"text", text:"..."}` parts. Recurse into nested text/thinking/content arrays-and-objects, then re-tag the resulting parts as thinking when the outer block declared a thinking-style type (`thinking`, `reasoning`, or `reasoning.text`). Same recursion is safe for nested `text` and `content` shapes — they fall through as text deltas. 2. Missing CHANGELOG entry. Add an Unreleased `### Fixes` bullet with contributor credit per the repo policy in CLAUDE.md ("every added entry must include at least one Thanks @author attribution"). Add 4 round-2 regression tests: - type:thinking block with thinking: [array of text parts] flattens to per-part thinking deltas keyed on the outer signature - type:reasoning block with text: [array of text parts] flattens to per-part thinking deltas - untyped block with content: [array of text parts] flattens to text - nested thinking block with only empty sub-blocks returns no parts (the "never emit [object Object]" invariant still holds for the recursive case) Refs openclaw#75268, openclaw#70806
8723d35 to
3f45c4a
Compare
…gelog credit Round-1 review on PR openclaw#75339 caught two gaps: 1. Nested thinking arrays were dropped. The original normalizer only read `record.thinking` as a string, but the Mistral typed-block reproduction includes `type:"thinking"` blocks whose `thinking` value is itself an array of `{type:"text", text:"..."}` parts. Recurse into nested text/thinking/content arrays-and-objects, then re-tag the resulting parts as thinking when the outer block declared a thinking-style type (`thinking`, `reasoning`, or `reasoning.text`). Same recursion is safe for nested `text` and `content` shapes — they fall through as text deltas. 2. Missing CHANGELOG entry. Add an Unreleased `### Fixes` bullet with contributor credit per the repo policy in CLAUDE.md ("every added entry must include at least one Thanks @author attribution"). Add 4 round-2 regression tests: - type:thinking block with thinking: [array of text parts] flattens to per-part thinking deltas keyed on the outer signature - type:reasoning block with text: [array of text parts] flattens to per-part thinking deltas - untyped block with content: [array of text parts] flattens to text - nested thinking block with only empty sub-blocks returns no parts (the "never emit [object Object]" invariant still holds for the recursive case) Refs openclaw#75268, openclaw#70806
3f45c4a to
35086db
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 35086db45e
ℹ️ 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".
| record.text !== undefined && record.text !== null && typeof record.text !== "string" | ||
| ? record.text | ||
| : record.thinking !== undefined && | ||
| record.thinking !== null && | ||
| typeof record.thinking !== "string" |
There was a problem hiding this comment.
Preserve string fallback when structured
text is non-string
This branch prioritizes any non-string record.text as nestedSource, so payloads like { text: {...}, content: "final answer" } or { text: {...}, thinking: "..." } recurse into the object and return [], dropping the valid string field entirely. That is a regression introduced by the new normalizer: a chunk can now disappear even though it still carries usable text in content/thinking, which loses assistant output instead of only filtering unsupported structures.
Useful? React with 👍 / 👎.
…ject Object] in chat replies Mistral with native reasoning enabled (https://docs.mistral.ai/studio-api/conversations/reasoning/native) returns OpenAI-compatible streaming `delta.content` as an array of typed blocks like: {"delta": {"content": [{"type": "thinking", "thinking": "..."}, {"type": "text", "text": "..."}]}} The OpenAI baseline ships `delta.content` as a flat string. The transport loop in src/agents/openai-transport-stream.ts treated content as a string unconditionally, so the array fell through to `appendTextDelta` and downstream string concatenation produced "[object Object]" repeating per block in user-visible chat replies. The corruption then leaked into session files and memory, exactly the symptoms in openclaw#75268 (and the closed predecessor openclaw#70806). The same family of structured payloads can also appear under `reasoning_content`. Add `normalizeStructuredContentDelta()` that handles the three observed shapes (string, array of typed blocks, single block object) and routes `thinking` / `reasoning` / `reasoning.text` types to thinking deltas and everything else to text deltas. Reuse the same normalizer from the `reasoning_content`/`reasoning`/`reasoning_text` fallback path inside `getCompletionsReasoningDeltas` so non-string payloads in those fields also stop collapsing to "[object Object]". Add 7 regression tests: - plain string content unchanged - empty/null/undefined content yields no parts - array of mixed thinking + text blocks flattens correctly - `reasoning` and `reasoning.text` block types are recognized as thinking - untyped block with `content` field falls back to text - unrecognized objects never emit the literal "[object Object]" - empty/non-string text fields are skipped, valid ones kept Refs openclaw#75268, openclaw#70806
…gelog credit Round-1 review on PR openclaw#75339 caught two gaps: 1. Nested thinking arrays were dropped. The original normalizer only read `record.thinking` as a string, but the Mistral typed-block reproduction includes `type:"thinking"` blocks whose `thinking` value is itself an array of `{type:"text", text:"..."}` parts. Recurse into nested text/thinking/content arrays-and-objects, then re-tag the resulting parts as thinking when the outer block declared a thinking-style type (`thinking`, `reasoning`, or `reasoning.text`). Same recursion is safe for nested `text` and `content` shapes — they fall through as text deltas. 2. Missing CHANGELOG entry. Add an Unreleased `### Fixes` bullet with contributor credit per the repo policy in CLAUDE.md ("every added entry must include at least one Thanks @author attribution"). Add 4 round-2 regression tests: - type:thinking block with thinking: [array of text parts] flattens to per-part thinking deltas keyed on the outer signature - type:reasoning block with text: [array of text parts] flattens to per-part thinking deltas - untyped block with content: [array of text parts] flattens to text - nested thinking block with only empty sub-blocks returns no parts (the "never emit [object Object]" invariant still holds for the recursive case) Refs openclaw#75268, openclaw#70806
35086db to
4ebe0ce
Compare
Follow-up notes for reviewers — invariant lock and extension surfacePosting a deeper write-up of what the new tests actually pin down, since the [object Object] regression has bitten this surface twice already (#70806 closed, #75268 here) and a tight invariant statement helps the next person evaluating a delta-shape PR. Soundness invariant pinned by the testsLet
That last row is the load-bearing one — it's what the bot review and reporter actually care about, and the test makes it impossible to regress without breaking the assertion. Edge case worth noting for future block-type expansionThe block-type allow-list (
Why the helper lives where it doesI deliberately kept Cross-reference with #67203PR #67203 extended |
Bug being fixed
Closes #75268. Same family of bug as the closed #70806.
Mistral with native reasoning enabled returns OpenAI-compatible streaming
delta.contentas an array of typed blocks instead of a flat string (per Mistral native reasoning docs):{"delta": {"content": [ {"type": "thinking", "thinking": "Let me think..."}, {"type": "text", "text": "Here is the answer."} ]}}The transport loop at
src/agents/openai-transport-stream.tstreateddelta.contentas a string unconditionally:So the array fell through to
appendTextDeltaand downstream string concatenation produced"[object Object]"repeating once per block in user-visible chat replies. The corruption then propagated into session transcript files and memory, matching the reporter's symptoms exactly.Fix
Add
normalizeStructuredContentDelta()that handles the three observed shapes:string"hello"[{type: "thinking", ...}, {type: "text", ...}]{type: "thinking", thinking: "..."}Block types
thinking/reasoning/reasoning.textroute to thinking deltas; anything else with a text/content/thinking string field routes to text deltas. Unrecognized objects with no string-valued field produce zero parts (no[object Object]leak).Reused the same normalizer in the
reasoning_content/reasoning/reasoning_textfallback insidegetCompletionsReasoningDeltas, so non-string payloads in those fields also stop collapsing to[object Object].Why this is the best fix
[object Object]never appears in normalized parts even for unrecognized object shapes.getCompletionsReasoningDeltasto handle arrays, but it doesn't touchdelta.contentitself, which is the actual user-visible leak path described in [Bug]: [object Object][object Object]... in agents messages and in memory with mistral thinking #75268. This PR covers bothdelta.contentand thereasoning_content/reasoning/reasoning_textfamily with one shared helper.Test plan
pnpm test src/agents/openai-transport-stream.test.ts— 103/103 pass (7 new + 96 existing)pnpm tsgo:core— cleanpnpm tsgo:core:test— cleanpnpm exec oxfmt --check— cleanreasoning+reasoning.textblock types, untyped block withcontentfield, unrecognized objects (explicit[object Object]non-leak assertion), and skipped empty/non-string text fields.#75268
Real behavior proof