Skip to content

Commit 8248b3e

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 f356077 commit 8248b3e

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
@@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
1111

1212
### Fixes
1313

14+
- Agents/OpenAI-completions stream: normalize structured `delta.content` and `reasoning_content` arrays of typed blocks (including nested Mistral `type:"thinking"` blocks whose `thinking` value is itself an array of text sub-blocks) so reasoning-capable Mistral models no longer produce `[object Object]` runs in user-visible chat replies, session transcripts, and memory. Fixes #75268; refs #70806. Thanks @lonexreb.
1415
- Agents/commitments: keep inferred follow-ups internal when heartbeat target is none, strip raw source text from stored commitments, disable tools during due-commitment heartbeat turns, bound hidden extraction queue growth, expire stale commitments, and add QA/Docker safety coverage. Thanks @vignesh07.
1516
- Plugins/runtime-deps: accept already materialized package-level runtime-deps supersets as converged, so later lazy plugin activation no longer prunes and relaunches `pnpm install` after gateway startup pre-staging, reducing event-loop pressure from repeated runtime-deps repair on packaged installs. Fixes #75283; refs #75297 and #72338. Thanks @brokemac79, @lisandromachado, and @midhunmonachan.
1617
- TTS/providers: keep bundled speech-provider compat fallback available when plugins are globally disabled, so cold gateway and CLI startup can still resolve fallback speech providers instead of leaving explicit TTS provider selection with no registered providers. Refs #75265. Thanks @sliekens.

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4202,4 +4202,48 @@ describe("normalizeStructuredContentDelta", () => {
42024202
]),
42034203
).toEqual([{ kind: "text", text: "kept" }]);
42044204
});
4205+
4206+
// Round-2 regression for #75268: a `type:"thinking"` block can carry its
4207+
// payload as a nested array of typed text sub-blocks instead of a flat
4208+
// string. Recursively flatten and re-tag as thinking.
4209+
it("flattens a nested thinking block whose `thinking` value is an array of text parts", () => {
4210+
expect(
4211+
normalizeStructuredContentDelta({
4212+
type: "thinking",
4213+
thinking: [
4214+
{ type: "text", text: "step one " },
4215+
{ type: "text", text: "step two" },
4216+
],
4217+
}),
4218+
).toEqual([
4219+
{ kind: "thinking", signature: "thinking", text: "step one " },
4220+
{ kind: "thinking", signature: "thinking", text: "step two" },
4221+
]);
4222+
});
4223+
4224+
it("flattens a nested reasoning block whose `text` value is an array of text parts", () => {
4225+
expect(
4226+
normalizeStructuredContentDelta({
4227+
type: "reasoning",
4228+
text: [{ type: "text", text: "internal thought" }],
4229+
}),
4230+
).toEqual([{ kind: "thinking", signature: "reasoning", text: "internal thought" }]);
4231+
});
4232+
4233+
it("flattens a nested untyped block whose `content` value is an array of text parts as text", () => {
4234+
expect(
4235+
normalizeStructuredContentDelta({
4236+
content: [{ type: "text", text: "visible reply" }],
4237+
}),
4238+
).toEqual([{ kind: "text", text: "visible reply" }]);
4239+
});
4240+
4241+
it("returns no parts when a nested thinking block contains only empty sub-blocks", () => {
4242+
expect(
4243+
normalizeStructuredContentDelta({
4244+
type: "thinking",
4245+
thinking: [{ type: "text", text: "" }, { type: "text" }],
4246+
}),
4247+
).toEqual([]);
4248+
});
42054249
});

src/agents/openai-transport-stream.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1596,6 +1596,37 @@ function normalizeStructuredContentDelta(value: unknown): StructuredContentDelta
15961596
if (typeof value === "object") {
15971597
const record = value as Record<string, unknown>;
15981598
const type = typeof record.type === "string" ? record.type : undefined;
1599+
const isThinkingType = type === "thinking" || type === "reasoning" || type === "reasoning.text";
1600+
// Mistral's typed-block reasoning shape can nest sub-blocks: a `type:"thinking"`
1601+
// block may carry `thinking: [{type:"text", text:"..."}, ...]` rather than a
1602+
// flat string. Recurse into nested arrays/objects, then re-tag the resulting
1603+
// text parts as thinking when the outer block declared a thinking-style type.
1604+
const nestedSource =
1605+
record.text !== undefined && record.text !== null && typeof record.text !== "string"
1606+
? record.text
1607+
: record.thinking !== undefined &&
1608+
record.thinking !== null &&
1609+
typeof record.thinking !== "string"
1610+
? record.thinking
1611+
: record.content !== undefined &&
1612+
record.content !== null &&
1613+
typeof record.content !== "string"
1614+
? record.content
1615+
: undefined;
1616+
if (nestedSource !== undefined) {
1617+
const nested = normalizeStructuredContentDelta(nestedSource);
1618+
if (nested.length === 0) {
1619+
return [];
1620+
}
1621+
if (isThinkingType) {
1622+
return nested.map((part) => ({
1623+
kind: "thinking",
1624+
signature: type ?? "thinking",
1625+
text: part.text,
1626+
}));
1627+
}
1628+
return nested;
1629+
}
15991630
const candidateText =
16001631
typeof record.text === "string"
16011632
? record.text
@@ -1607,7 +1638,7 @@ function normalizeStructuredContentDelta(value: unknown): StructuredContentDelta
16071638
if (!candidateText || candidateText.length === 0) {
16081639
return [];
16091640
}
1610-
if (type === "thinking" || type === "reasoning" || type === "reasoning.text") {
1641+
if (isThinkingType) {
16111642
return [{ kind: "thinking", signature: type, text: candidateText }];
16121643
}
16131644
return [{ kind: "text", text: candidateText }];

0 commit comments

Comments
 (0)