Skip to content

Commit 1056c81

Browse files
steipeteHiddenPuppy
andcommitted
fix(xiaomi): surface MiMo reasoning-only finals
Co-authored-by: HiddenPuppy <[email protected]>
1 parent 8859e89 commit 1056c81

6 files changed

Lines changed: 357 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai
1313
- Control UI/WebChat: focus the composer when users click the visible input chrome and restore larger, labeled desktop composer controls while preserving compact mobile taps. Fixes #45656. Thanks @BunsDev.
1414
- System events: keep owner downgrades in structured metadata while rendering queued prompt text as plain `System:` lines, preserving least-privilege wakeups without prompt-visible trust labels. (#82067)
1515
- Providers/Xiaomi: preserve MiMo `reasoning_content` on multi-turn tool-call replay, including custom Xiaomi-compatible proxy routes, so follow-up turns no longer fail with `400 Param Incorrect`. Fixes #81419. (#81589) Thanks @lovelefeng-glitch and @jimdawdy-hub.
16+
- Providers/Xiaomi: promote legacy MiMo V2 reasoning-only final answers to visible text, including Xiaomi-compatible proxy routes, so `mimo-v2-pro` and `mimo-v2-omni` replies no longer appear blank when the answer arrives in `reasoning_content`. Fixes #60261. (#60304) Thanks @HiddenPuppy.
1617
- Memory search: stop using chokidar write-stability polling for memory and QMD watchers so large Markdown extraPath trees no longer build up regular file descriptors; changed files now settle through the existing debounced sync queue. Fixes #77327 and #78224. (#81802) Thanks @frankekn, @loyur, and @JanPlessow.
1718

1819
## 2026.5.14

extensions/xiaomi/index.test.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { StreamFn } from "@earendil-works/pi-agent-core";
12
import type { Context, Model } from "@earendil-works/pi-ai";
23
import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
34
import {
@@ -30,6 +31,10 @@ type ReplayToolCall = {
3031
};
3132

3233
type RegisteredProvider = Awaited<ReturnType<typeof registerSingleProviderPlugin>>;
34+
type FakeStream = {
35+
result: () => Promise<unknown>;
36+
[Symbol.asyncIterator]: () => AsyncIterator<unknown>;
37+
};
3338

3439
const emptyUsage = {
3540
input: 0,
@@ -141,6 +146,29 @@ function createPayloadCapturingStream(capture: PayloadCapture, model: OpenAIComp
141146
};
142147
}
143148

149+
function createFakeStream(params: { events: unknown[]; resultMessage: unknown }): FakeStream {
150+
return {
151+
async result() {
152+
return params.resultMessage;
153+
},
154+
[Symbol.asyncIterator]() {
155+
return (async function* () {
156+
for (const event of params.events) {
157+
yield event;
158+
}
159+
})();
160+
},
161+
};
162+
}
163+
164+
function createResultStreamFn(params: { events?: unknown[]; resultMessage: unknown }): StreamFn {
165+
return () =>
166+
createFakeStream({
167+
events: params.events ?? [],
168+
resultMessage: params.resultMessage,
169+
}) as ReturnType<StreamFn>;
170+
}
171+
144172
function requireThinkingWrapper(
145173
wrapper: ReturnType<typeof createMiMoThinkingWrapper>,
146174
label: string,
@@ -312,4 +340,99 @@ describe("xiaomi provider plugin", () => {
312340
"reasoning_content",
313341
);
314342
});
343+
344+
it.each(["mimo-v2-pro", "mimo-v2-omni"] as const)(
345+
"promotes reasoning-only terminal output to visible text for %s",
346+
async (modelId) => {
347+
const model = mimoReasoningModel(modelId);
348+
const wrapped = requireThinkingWrapper(
349+
createMiMoThinkingWrapper(
350+
createResultStreamFn({
351+
events: [
352+
{
353+
type: "message_end",
354+
message: {
355+
role: "assistant",
356+
content: [{ type: "thinking", thinking: "MiMo final answer" }],
357+
stopReason: "stop",
358+
},
359+
},
360+
],
361+
resultMessage: {
362+
role: "assistant",
363+
content: [{ type: "thinking", thinking: "MiMo final answer" }],
364+
stopReason: "stop",
365+
},
366+
}),
367+
"high",
368+
),
369+
modelId,
370+
);
371+
372+
const stream = (await wrapped(model, { messages: [] } as Context, {})) as FakeStream;
373+
const events: unknown[] = [];
374+
for await (const event of stream) {
375+
events.push(event);
376+
}
377+
378+
expect(events).toEqual([
379+
{
380+
type: "message_end",
381+
message: {
382+
role: "assistant",
383+
content: [{ type: "text", text: "MiMo final answer" }],
384+
stopReason: "stop",
385+
},
386+
},
387+
]);
388+
await expect(stream.result()).resolves.toEqual({
389+
role: "assistant",
390+
content: [{ type: "text", text: "MiMo final answer" }],
391+
stopReason: "stop",
392+
});
393+
},
394+
);
395+
396+
it("does not promote reasoning when the MiMo assistant turn also has text or tool calls", async () => {
397+
const model = mimoReasoningModel("mimo-v2-pro");
398+
const textMessage = {
399+
role: "assistant",
400+
content: [
401+
{ type: "thinking", thinking: "internal" },
402+
{ type: "text", text: "already visible" },
403+
],
404+
stopReason: "stop",
405+
};
406+
const toolMessage = {
407+
role: "assistant",
408+
content: [{ type: "thinking", thinking: "call reasoning" }, readToolCall],
409+
stopReason: "toolUse",
410+
};
411+
412+
for (const resultMessage of [textMessage, toolMessage]) {
413+
const wrapped = requireThinkingWrapper(
414+
createMiMoThinkingWrapper(createResultStreamFn({ resultMessage }), "high"),
415+
"mixed-content",
416+
);
417+
const stream = (await wrapped(model, { messages: [] } as Context, {})) as FakeStream;
418+
419+
await expect(stream.result()).resolves.toEqual(resultMessage);
420+
}
421+
});
422+
423+
it("does not promote reasoning-only output for newer MiMo replay models", async () => {
424+
const model = mimoReasoningModel("mimo-v2.5-pro");
425+
const resultMessage = {
426+
role: "assistant",
427+
content: [{ type: "thinking", thinking: "actual reasoning" }],
428+
stopReason: "stop",
429+
};
430+
const wrapped = requireThinkingWrapper(
431+
createMiMoThinkingWrapper(createResultStreamFn({ resultMessage }), "high"),
432+
"mimo-v2.5-pro",
433+
);
434+
const stream = (await wrapped(model, { messages: [] } as Context, {})) as FakeStream;
435+
436+
await expect(stream.result()).resolves.toEqual(resultMessage);
437+
});
315438
});

extensions/xiaomi/stream.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,45 @@
1+
import type { StreamFn } from "@earendil-works/pi-agent-core";
12
import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry";
2-
import { createDeepSeekV4OpenAICompatibleThinkingWrapper } from "openclaw/plugin-sdk/provider-stream-shared";
3+
import {
4+
createDeepSeekV4OpenAICompatibleThinkingWrapper,
5+
createThinkingOnlyFinalTextWrapper,
6+
} from "openclaw/plugin-sdk/provider-stream-shared";
37
import { isMiMoReasoningModelRef } from "./thinking.js";
48

9+
const MIMO_REASONING_AS_VISIBLE_TEXT_MODEL_IDS = new Set(["mimo-v2-pro", "mimo-v2-omni"]);
10+
11+
function normalizeMiMoModelId(modelId: unknown): string | undefined {
12+
if (typeof modelId !== "string") {
13+
return undefined;
14+
}
15+
const normalized = modelId.trim().toLowerCase().split(":", 1)[0];
16+
if (!normalized) {
17+
return undefined;
18+
}
19+
const parts = normalized.split("/").filter(Boolean);
20+
return parts[parts.length - 1] ?? normalized;
21+
}
22+
23+
function shouldPromoteMiMoReasoningToVisibleText(model: Parameters<StreamFn>[0]): boolean {
24+
return (
25+
model.provider === "xiaomi" &&
26+
MIMO_REASONING_AS_VISIBLE_TEXT_MODEL_IDS.has(normalizeMiMoModelId(model.id) ?? "")
27+
);
28+
}
29+
530
export function createMiMoThinkingWrapper(
631
baseStreamFn: ProviderWrapStreamFnContext["streamFn"],
732
thinkingLevel: ProviderWrapStreamFnContext["thinkingLevel"],
833
): ProviderWrapStreamFnContext["streamFn"] {
9-
return createDeepSeekV4OpenAICompatibleThinkingWrapper({
34+
const wrapped = createDeepSeekV4OpenAICompatibleThinkingWrapper({
1035
baseStreamFn,
1136
thinkingLevel,
1237
shouldPatchModel: isMiMoReasoningModelRef,
1338
});
39+
// Legacy MiMo V2 can put the final user-visible answer in reasoning_content.
40+
// Only promote terminal thinking-only output; replay/tool-call reasoning stays untouched.
41+
return createThinkingOnlyFinalTextWrapper({
42+
baseStreamFn: wrapped,
43+
shouldPatchModel: shouldPromoteMiMoReasoningToVisibleText,
44+
});
1445
}

src/agents/pi-embedded-runner-extraparams.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { StreamFn } from "@earendil-works/pi-agent-core";
22
import type { Context, Model, SimpleStreamOptions } from "@earendil-works/pi-ai";
3+
import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
34
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
45
import { __testing as extraParamsTesting } from "./pi-embedded-runner/extra-params.js";
56

@@ -724,6 +725,64 @@ describe("applyExtraParamsToAgent", () => {
724725
expect(messages[2]).not.toHaveProperty("reasoning_content");
725726
});
726727

728+
it("promotes reasoning-only MiMo V2 proxy finals to visible text", async () => {
729+
const resultMessage = {
730+
role: "assistant",
731+
content: [{ type: "thinking", thinking: "proxy final answer" }],
732+
api: "openai-completions",
733+
provider: "opencode",
734+
model: "xiaomi/mimo-v2-pro",
735+
usage: {
736+
input: 0,
737+
output: 0,
738+
cacheRead: 0,
739+
cacheWrite: 0,
740+
totalTokens: 0,
741+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
742+
},
743+
stopReason: "stop",
744+
timestamp: 1,
745+
} as const;
746+
const baseStreamFn: StreamFn = () => {
747+
const stream = createAssistantMessageEventStream();
748+
queueMicrotask(() => {
749+
stream.push({ type: "done", reason: "stop", message: resultMessage as never });
750+
});
751+
return stream;
752+
};
753+
const agent = { streamFn: baseStreamFn };
754+
applyExtraParamsToAgent(agent, undefined, "opencode", "xiaomi/mimo-v2-pro", undefined, "high");
755+
756+
const model = {
757+
api: "openai-completions",
758+
provider: "opencode",
759+
id: "xiaomi/mimo-v2-pro",
760+
} as Model<"openai-completions">;
761+
const stream = await agent.streamFn?.(model, { messages: [] }, {});
762+
expect(stream).toBeDefined();
763+
if (!stream) {
764+
throw new Error("expected stream function");
765+
}
766+
const events: unknown[] = [];
767+
for await (const event of stream) {
768+
events.push(event);
769+
}
770+
771+
expect(events).toEqual([
772+
{
773+
type: "done",
774+
reason: "stop",
775+
message: {
776+
...resultMessage,
777+
content: [{ type: "text", text: "proxy final answer" }],
778+
},
779+
},
780+
]);
781+
await expect(stream.result()).resolves.toMatchObject({
782+
content: [{ type: "text", text: "proxy final answer" }],
783+
});
784+
});
785+
727786
it("strips xai Responses reasoning payload fields", () => {
728787
const payload = runResponsesPayloadMutationCase({
729788
applyProvider: "xai",

src/agents/pi-embedded-runner/extra-params.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import { streamSimple } from "@earendil-works/pi-ai";
44
import type { SettingsManager } from "@earendil-works/pi-coding-agent";
55
import type { ThinkLevel } from "../../auto-reply/thinking.js";
66
import type { OpenClawConfig } from "../../config/types.openclaw.js";
7-
import { createDeepSeekV4OpenAICompatibleThinkingWrapper } from "../../plugin-sdk/provider-stream-shared.js";
7+
import {
8+
createDeepSeekV4OpenAICompatibleThinkingWrapper,
9+
createThinkingOnlyFinalTextWrapper,
10+
} from "../../plugin-sdk/provider-stream-shared.js";
811
import {
912
prepareProviderExtraParams as prepareProviderExtraParamsRuntime,
1013
type ProviderRuntimePluginHandle,
@@ -762,6 +765,12 @@ function applyPostPluginStreamWrappers(
762765
thinkingLevel: ctx.thinkingLevel,
763766
shouldPatchModel: isMiMoReasoningOpenAICompatibleModel,
764767
});
768+
// Legacy MiMo V2 can put final visible answers in reasoning_content. Apply
769+
// the response-side fallback here for custom Xiaomi-compatible proxy routes.
770+
ctx.agent.streamFn = createThinkingOnlyFinalTextWrapper({
771+
baseStreamFn: ctx.agent.streamFn,
772+
shouldPatchModel: isMiMoReasoningAsVisibleTextOpenAICompatibleModel,
773+
});
765774

766775
// Guard Google-family payloads against invalid negative thinking budgets
767776
// emitted by upstream model-ID heuristics for Gemini 3.1 variants.
@@ -848,6 +857,7 @@ const MIMO_REASONING_OPENAI_COMPATIBLE_MODEL_IDS = new Set([
848857
"mimo-v2.5",
849858
"mimo-v2.5-pro",
850859
]);
860+
const MIMO_REASONING_AS_VISIBLE_TEXT_MODEL_IDS = new Set(["mimo-v2-pro", "mimo-v2-omni"]);
851861

852862
function isMiMoReasoningOpenAICompatibleModel(model: Parameters<StreamFn>[0]): boolean {
853863
const normalizedModelId = normalizeDeepSeekV4CandidateId(model.id);
@@ -858,6 +868,17 @@ function isMiMoReasoningOpenAICompatibleModel(model: Parameters<StreamFn>[0]): b
858868
);
859869
}
860870

871+
function isMiMoReasoningAsVisibleTextOpenAICompatibleModel(
872+
model: Parameters<StreamFn>[0],
873+
): boolean {
874+
const normalizedModelId = normalizeDeepSeekV4CandidateId(model.id);
875+
return (
876+
model.api === "openai-completions" &&
877+
normalizedModelId !== undefined &&
878+
MIMO_REASONING_AS_VISIBLE_TEXT_MODEL_IDS.has(normalizedModelId)
879+
);
880+
}
881+
861882
/**
862883
* Apply extra params (like temperature) to an agent's streamFn.
863884
* Also applies verified provider-specific request wrappers, such as OpenRouter attribution.

0 commit comments

Comments
 (0)