Skip to content

Commit 4ebe0ce

Browse files
committed
fix(agents): handle nested Mistral thinking-block arrays and add changelog credit
Round-1 review on PR #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 #75268, #70806
1 parent f2b02dc commit 4ebe0ce

3 files changed

Lines changed: 77 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ Docs: https://docs.openclaw.ai
7070

7171
### Fixes
7272

73+
- Agents/openai-completions: normalize structured `delta.content` blocks (including nested Mistral thinking-block arrays) at the shared OpenAI-compatible stream boundary so non-string content does not surface as `[object Object]` in chat replies, transcripts, and memory. Fixes #75268. Thanks @lonexreb.
7374
- Gateway/OpenAI-compatible: send the assistant role SSE chunk as soon as streaming chat-completion headers are accepted, so cold agent setup cannot leave `/v1/chat/completions` clients with a bodyless 200 response until their idle timeout fires.
7475
- Agents/media: avoid direct generated-media completion fallback while the announce-agent run is still pending, so async video and music completions do not duplicate raw media messages. (#77754)
7576
- TUI/sessions: bound the session picker to recent rows and use exact lookup-style refreshes for the active session, so dusty stores no longer make TUI hydrate weeks-old transcripts before becoming responsive. Thanks @vincentkoc.

src/agents/openai-transport-stream.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4498,4 +4498,48 @@ describe("normalizeStructuredContentDelta", () => {
44984498
]),
44994499
).toEqual([{ kind: "text", text: "kept" }]);
45004500
});
4501+
4502+
// Round-2 regression for #75268: a `type:"thinking"` block can carry its
4503+
// payload as a nested array of typed text sub-blocks instead of a flat
4504+
// string. Recursively flatten and re-tag as thinking.
4505+
it("flattens a nested thinking block whose `thinking` value is an array of text parts", () => {
4506+
expect(
4507+
normalizeStructuredContentDelta({
4508+
type: "thinking",
4509+
thinking: [
4510+
{ type: "text", text: "step one " },
4511+
{ type: "text", text: "step two" },
4512+
],
4513+
}),
4514+
).toEqual([
4515+
{ kind: "thinking", signature: "thinking", text: "step one " },
4516+
{ kind: "thinking", signature: "thinking", text: "step two" },
4517+
]);
4518+
});
4519+
4520+
it("flattens a nested reasoning block whose `text` value is an array of text parts", () => {
4521+
expect(
4522+
normalizeStructuredContentDelta({
4523+
type: "reasoning",
4524+
text: [{ type: "text", text: "internal thought" }],
4525+
}),
4526+
).toEqual([{ kind: "thinking", signature: "reasoning", text: "internal thought" }]);
4527+
});
4528+
4529+
it("flattens a nested untyped block whose `content` value is an array of text parts as text", () => {
4530+
expect(
4531+
normalizeStructuredContentDelta({
4532+
content: [{ type: "text", text: "visible reply" }],
4533+
}),
4534+
).toEqual([{ kind: "text", text: "visible reply" }]);
4535+
});
4536+
4537+
it("returns no parts when a nested thinking block contains only empty sub-blocks", () => {
4538+
expect(
4539+
normalizeStructuredContentDelta({
4540+
type: "thinking",
4541+
thinking: [{ type: "text", text: "" }, { type: "text" }],
4542+
}),
4543+
).toEqual([]);
4544+
});
45014545
});

src/agents/openai-transport-stream.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1630,6 +1630,37 @@ function normalizeStructuredContentDelta(value: unknown): StructuredContentDelta
16301630
if (typeof value === "object") {
16311631
const record = value as Record<string, unknown>;
16321632
const type = typeof record.type === "string" ? record.type : undefined;
1633+
const isThinkingType = type === "thinking" || type === "reasoning" || type === "reasoning.text";
1634+
// Mistral's typed-block reasoning shape can nest sub-blocks: a `type:"thinking"`
1635+
// block may carry `thinking: [{type:"text", text:"..."}, ...]` rather than a
1636+
// flat string. Recurse into nested arrays/objects, then re-tag the resulting
1637+
// text parts as thinking when the outer block declared a thinking-style type.
1638+
const nestedSource =
1639+
record.text !== undefined && record.text !== null && typeof record.text !== "string"
1640+
? record.text
1641+
: record.thinking !== undefined &&
1642+
record.thinking !== null &&
1643+
typeof record.thinking !== "string"
1644+
? record.thinking
1645+
: record.content !== undefined &&
1646+
record.content !== null &&
1647+
typeof record.content !== "string"
1648+
? record.content
1649+
: undefined;
1650+
if (nestedSource !== undefined) {
1651+
const nested = normalizeStructuredContentDelta(nestedSource);
1652+
if (nested.length === 0) {
1653+
return [];
1654+
}
1655+
if (isThinkingType) {
1656+
return nested.map((part) => ({
1657+
kind: "thinking",
1658+
signature: type ?? "thinking",
1659+
text: part.text,
1660+
}));
1661+
}
1662+
return nested;
1663+
}
16331664
const candidateText =
16341665
typeof record.text === "string"
16351666
? record.text
@@ -1641,7 +1672,7 @@ function normalizeStructuredContentDelta(value: unknown): StructuredContentDelta
16411672
if (!candidateText || candidateText.length === 0) {
16421673
return [];
16431674
}
1644-
if (type === "thinking" || type === "reasoning" || type === "reasoning.text") {
1675+
if (isThinkingType) {
16451676
return [{ kind: "thinking", signature: type, text: candidateText }];
16461677
}
16471678
return [{ kind: "text", text: candidateText }];

0 commit comments

Comments
 (0)