From 8cc1aee9d8d8cdc4b8b6011d93bf697876d240b8 Mon Sep 17 00:00:00 2001 From: Jerome Xu Date: Fri, 15 May 2026 21:12:44 +0800 Subject: [PATCH] fix(xiaomi): surface MiMo reasoning-only finals (#60304) Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + extensions/xiaomi/index.test.ts | 123 ++++++++++++++++++ extensions/xiaomi/stream.ts | 35 ++++- .../pi-embedded-runner-extraparams.test.ts | 59 +++++++++ src/agents/pi-embedded-runner/extra-params.ts | 23 +++- src/plugin-sdk/provider-stream-shared.ts | 119 +++++++++++++++++ 6 files changed, 357 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a26753856b0..4e96bd30101 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai - Slack: keep finalized draft-preview replies visible when a later same-turn tool warning is delivered normally instead of clearing the edited answer. Fixes #81903. (#81979) Thanks @neeravmakwana. - 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. - Slack/plugins: route plugin-owned modal `view_submission` and `view_closed` events through Slack interactive handlers before compacting the agent-visible system event, so plugins can persist full submitted form state while the transcript stays compact. Fixes #82102. Thanks @shannon0430. +- 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. - 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. ## 2026.5.14 diff --git a/extensions/xiaomi/index.test.ts b/extensions/xiaomi/index.test.ts index a857b732f95..1fa42066d92 100644 --- a/extensions/xiaomi/index.test.ts +++ b/extensions/xiaomi/index.test.ts @@ -1,3 +1,4 @@ +import type { StreamFn } from "@earendil-works/pi-agent-core"; import type { Context, Model } from "@earendil-works/pi-ai"; import { createAssistantMessageEventStream } from "@earendil-works/pi-ai"; import { @@ -30,6 +31,10 @@ type ReplayToolCall = { }; type RegisteredProvider = Awaited>; +type FakeStream = { + result: () => Promise; + [Symbol.asyncIterator]: () => AsyncIterator; +}; const emptyUsage = { input: 0, @@ -141,6 +146,29 @@ function createPayloadCapturingStream(capture: PayloadCapture, model: OpenAIComp }; } +function createFakeStream(params: { events: unknown[]; resultMessage: unknown }): FakeStream { + return { + async result() { + return params.resultMessage; + }, + [Symbol.asyncIterator]() { + return (async function* () { + for (const event of params.events) { + yield event; + } + })(); + }, + }; +} + +function createResultStreamFn(params: { events?: unknown[]; resultMessage: unknown }): StreamFn { + return () => + createFakeStream({ + events: params.events ?? [], + resultMessage: params.resultMessage, + }) as ReturnType; +} + function requireThinkingWrapper( wrapper: ReturnType, label: string, @@ -312,4 +340,99 @@ describe("xiaomi provider plugin", () => { "reasoning_content", ); }); + + it.each(["mimo-v2-pro", "mimo-v2-omni"] as const)( + "promotes reasoning-only terminal output to visible text for %s", + async (modelId) => { + const model = mimoReasoningModel(modelId); + const wrapped = requireThinkingWrapper( + createMiMoThinkingWrapper( + createResultStreamFn({ + events: [ + { + type: "message_end", + message: { + role: "assistant", + content: [{ type: "thinking", thinking: "MiMo final answer" }], + stopReason: "stop", + }, + }, + ], + resultMessage: { + role: "assistant", + content: [{ type: "thinking", thinking: "MiMo final answer" }], + stopReason: "stop", + }, + }), + "high", + ), + modelId, + ); + + const stream = (await wrapped(model, { messages: [] } as Context, {})) as FakeStream; + const events: unknown[] = []; + for await (const event of stream) { + events.push(event); + } + + expect(events).toEqual([ + { + type: "message_end", + message: { + role: "assistant", + content: [{ type: "text", text: "MiMo final answer" }], + stopReason: "stop", + }, + }, + ]); + await expect(stream.result()).resolves.toEqual({ + role: "assistant", + content: [{ type: "text", text: "MiMo final answer" }], + stopReason: "stop", + }); + }, + ); + + it("does not promote reasoning when the MiMo assistant turn also has text or tool calls", async () => { + const model = mimoReasoningModel("mimo-v2-pro"); + const textMessage = { + role: "assistant", + content: [ + { type: "thinking", thinking: "internal" }, + { type: "text", text: "already visible" }, + ], + stopReason: "stop", + }; + const toolMessage = { + role: "assistant", + content: [{ type: "thinking", thinking: "call reasoning" }, readToolCall], + stopReason: "toolUse", + }; + + for (const resultMessage of [textMessage, toolMessage]) { + const wrapped = requireThinkingWrapper( + createMiMoThinkingWrapper(createResultStreamFn({ resultMessage }), "high"), + "mixed-content", + ); + const stream = (await wrapped(model, { messages: [] } as Context, {})) as FakeStream; + + await expect(stream.result()).resolves.toEqual(resultMessage); + } + }); + + it("does not promote reasoning-only output for newer MiMo replay models", async () => { + const model = mimoReasoningModel("mimo-v2.5-pro"); + const resultMessage = { + role: "assistant", + content: [{ type: "thinking", thinking: "actual reasoning" }], + stopReason: "stop", + }; + const wrapped = requireThinkingWrapper( + createMiMoThinkingWrapper(createResultStreamFn({ resultMessage }), "high"), + "mimo-v2.5-pro", + ); + const stream = (await wrapped(model, { messages: [] } as Context, {})) as FakeStream; + + await expect(stream.result()).resolves.toEqual(resultMessage); + }); }); diff --git a/extensions/xiaomi/stream.ts b/extensions/xiaomi/stream.ts index eee3b53242b..372ee87285c 100644 --- a/extensions/xiaomi/stream.ts +++ b/extensions/xiaomi/stream.ts @@ -1,14 +1,45 @@ +import type { StreamFn } from "@earendil-works/pi-agent-core"; import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry"; -import { createDeepSeekV4OpenAICompatibleThinkingWrapper } from "openclaw/plugin-sdk/provider-stream-shared"; +import { + createDeepSeekV4OpenAICompatibleThinkingWrapper, + createThinkingOnlyFinalTextWrapper, +} from "openclaw/plugin-sdk/provider-stream-shared"; import { isMiMoReasoningModelRef } from "./thinking.js"; +const MIMO_REASONING_AS_VISIBLE_TEXT_MODEL_IDS = new Set(["mimo-v2-pro", "mimo-v2-omni"]); + +function normalizeMiMoModelId(modelId: unknown): string | undefined { + if (typeof modelId !== "string") { + return undefined; + } + const normalized = modelId.trim().toLowerCase().split(":", 1)[0]; + if (!normalized) { + return undefined; + } + const parts = normalized.split("/").filter(Boolean); + return parts[parts.length - 1] ?? normalized; +} + +function shouldPromoteMiMoReasoningToVisibleText(model: Parameters[0]): boolean { + return ( + model.provider === "xiaomi" && + MIMO_REASONING_AS_VISIBLE_TEXT_MODEL_IDS.has(normalizeMiMoModelId(model.id) ?? "") + ); +} + export function createMiMoThinkingWrapper( baseStreamFn: ProviderWrapStreamFnContext["streamFn"], thinkingLevel: ProviderWrapStreamFnContext["thinkingLevel"], ): ProviderWrapStreamFnContext["streamFn"] { - return createDeepSeekV4OpenAICompatibleThinkingWrapper({ + const wrapped = createDeepSeekV4OpenAICompatibleThinkingWrapper({ baseStreamFn, thinkingLevel, shouldPatchModel: isMiMoReasoningModelRef, }); + // Legacy MiMo V2 can put the final user-visible answer in reasoning_content. + // Only promote terminal thinking-only output; replay/tool-call reasoning stays untouched. + return createThinkingOnlyFinalTextWrapper({ + baseStreamFn: wrapped, + shouldPatchModel: shouldPromoteMiMoReasoningToVisibleText, + }); } diff --git a/src/agents/pi-embedded-runner-extraparams.test.ts b/src/agents/pi-embedded-runner-extraparams.test.ts index 9a12e52ac8b..1cde6bdcfca 100644 --- a/src/agents/pi-embedded-runner-extraparams.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.test.ts @@ -1,5 +1,6 @@ import type { StreamFn } from "@earendil-works/pi-agent-core"; import type { Context, Model, SimpleStreamOptions } from "@earendil-works/pi-ai"; +import { createAssistantMessageEventStream } from "@earendil-works/pi-ai"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { __testing as extraParamsTesting } from "./pi-embedded-runner/extra-params.js"; @@ -724,6 +725,64 @@ describe("applyExtraParamsToAgent", () => { expect(messages[2]).not.toHaveProperty("reasoning_content"); }); + it("promotes reasoning-only MiMo V2 proxy finals to visible text", async () => { + const resultMessage = { + role: "assistant", + content: [{ type: "thinking", thinking: "proxy final answer" }], + api: "openai-completions", + provider: "opencode", + model: "xiaomi/mimo-v2-pro", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: 1, + } as const; + const baseStreamFn: StreamFn = () => { + const stream = createAssistantMessageEventStream(); + queueMicrotask(() => { + stream.push({ type: "done", reason: "stop", message: resultMessage as never }); + }); + return stream; + }; + const agent = { streamFn: baseStreamFn }; + applyExtraParamsToAgent(agent, undefined, "opencode", "xiaomi/mimo-v2-pro", undefined, "high"); + + const model = { + api: "openai-completions", + provider: "opencode", + id: "xiaomi/mimo-v2-pro", + } as Model<"openai-completions">; + const stream = await agent.streamFn?.(model, { messages: [] }, {}); + expect(stream).toBeDefined(); + if (!stream) { + throw new Error("expected stream function"); + } + const events: unknown[] = []; + for await (const event of stream) { + events.push(event); + } + + expect(events).toEqual([ + { + type: "done", + reason: "stop", + message: { + ...resultMessage, + content: [{ type: "text", text: "proxy final answer" }], + }, + }, + ]); + await expect(stream.result()).resolves.toMatchObject({ + content: [{ type: "text", text: "proxy final answer" }], + }); + }); + it("strips xai Responses reasoning payload fields", () => { const payload = runResponsesPayloadMutationCase({ applyProvider: "xai", diff --git a/src/agents/pi-embedded-runner/extra-params.ts b/src/agents/pi-embedded-runner/extra-params.ts index 8f7ff82a309..811e90ace0e 100644 --- a/src/agents/pi-embedded-runner/extra-params.ts +++ b/src/agents/pi-embedded-runner/extra-params.ts @@ -4,7 +4,10 @@ import { streamSimple } from "@earendil-works/pi-ai"; import type { SettingsManager } from "@earendil-works/pi-coding-agent"; import type { ThinkLevel } from "../../auto-reply/thinking.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { createDeepSeekV4OpenAICompatibleThinkingWrapper } from "../../plugin-sdk/provider-stream-shared.js"; +import { + createDeepSeekV4OpenAICompatibleThinkingWrapper, + createThinkingOnlyFinalTextWrapper, +} from "../../plugin-sdk/provider-stream-shared.js"; import { prepareProviderExtraParams as prepareProviderExtraParamsRuntime, type ProviderRuntimePluginHandle, @@ -762,6 +765,12 @@ function applyPostPluginStreamWrappers( thinkingLevel: ctx.thinkingLevel, shouldPatchModel: isMiMoReasoningOpenAICompatibleModel, }); + // Legacy MiMo V2 can put final visible answers in reasoning_content. Apply + // the response-side fallback here for custom Xiaomi-compatible proxy routes. + ctx.agent.streamFn = createThinkingOnlyFinalTextWrapper({ + baseStreamFn: ctx.agent.streamFn, + shouldPatchModel: isMiMoReasoningAsVisibleTextOpenAICompatibleModel, + }); // Guard Google-family payloads against invalid negative thinking budgets // emitted by upstream model-ID heuristics for Gemini 3.1 variants. @@ -848,6 +857,7 @@ const MIMO_REASONING_OPENAI_COMPATIBLE_MODEL_IDS = new Set([ "mimo-v2.5", "mimo-v2.5-pro", ]); +const MIMO_REASONING_AS_VISIBLE_TEXT_MODEL_IDS = new Set(["mimo-v2-pro", "mimo-v2-omni"]); function isMiMoReasoningOpenAICompatibleModel(model: Parameters[0]): boolean { const normalizedModelId = normalizeDeepSeekV4CandidateId(model.id); @@ -858,6 +868,17 @@ function isMiMoReasoningOpenAICompatibleModel(model: Parameters[0]): b ); } +function isMiMoReasoningAsVisibleTextOpenAICompatibleModel( + model: Parameters[0], +): boolean { + const normalizedModelId = normalizeDeepSeekV4CandidateId(model.id); + return ( + model.api === "openai-completions" && + normalizedModelId !== undefined && + MIMO_REASONING_AS_VISIBLE_TEXT_MODEL_IDS.has(normalizedModelId) + ); +} + /** * Apply extra params (like temperature) to an agent's streamFn. * Also applies verified provider-specific request wrappers, such as OpenRouter attribution. diff --git a/src/plugin-sdk/provider-stream-shared.ts b/src/plugin-sdk/provider-stream-shared.ts index f9fe7779197..85125d81440 100644 --- a/src/plugin-sdk/provider-stream-shared.ts +++ b/src/plugin-sdk/provider-stream-shared.ts @@ -232,6 +232,125 @@ export function createDeepSeekV4OpenAICompatibleThinkingWrapper(params: { }; } +type ThinkingOnlyFinalTextStream = Awaited>; + +function promoteThinkingOnlyFinalOutputToText(message: unknown): void { + if (!message || typeof message !== "object") { + return; + } + const record = message as { content?: unknown; stopReason?: unknown }; + if (record.stopReason !== "stop" && record.stopReason !== "length") { + return; + } + if (!Array.isArray(record.content) || record.content.length === 0) { + return; + } + + let hasVisibleText = false; + let hasToolCall = false; + let hasVisibleThinking = false; + for (const block of record.content) { + if (!block || typeof block !== "object") { + continue; + } + const typedBlock = block as { type?: unknown; text?: unknown; thinking?: unknown }; + if ( + typedBlock.type === "text" && + typeof typedBlock.text === "string" && + typedBlock.text.trim() + ) { + hasVisibleText = true; + } + if (typedBlock.type === "toolCall" || typedBlock.type === "tool_use") { + hasToolCall = true; + } + if ( + typedBlock.type === "thinking" && + typeof typedBlock.thinking === "string" && + typedBlock.thinking.trim() + ) { + hasVisibleThinking = true; + } + } + if (hasVisibleText || hasToolCall || !hasVisibleThinking) { + return; + } + + record.content = record.content.map((block) => { + if (!block || typeof block !== "object") { + return block; + } + const typedBlock = block as { type?: unknown; thinking?: unknown }; + if ( + typedBlock.type !== "thinking" || + typeof typedBlock.thinking !== "string" || + !typedBlock.thinking.trim() + ) { + return block; + } + return { type: "text", text: typedBlock.thinking }; + }); +} + +function wrapThinkingOnlyFinalTextStream( + stream: ThinkingOnlyFinalTextStream, +): ThinkingOnlyFinalTextStream { + const originalResult = stream.result.bind(stream); + stream.result = async () => { + const message = await originalResult(); + promoteThinkingOnlyFinalOutputToText(message); + return message; + }; + + const originalAsyncIterator = stream[Symbol.asyncIterator].bind(stream); + (stream as { [Symbol.asyncIterator]: typeof originalAsyncIterator })[Symbol.asyncIterator] = + function () { + const iterator = originalAsyncIterator(); + return { + async next() { + const result = await iterator.next(); + if (!result.done && result.value && typeof result.value === "object") { + const event = result.value as { partial?: unknown; message?: unknown }; + promoteThinkingOnlyFinalOutputToText(event.partial); + promoteThinkingOnlyFinalOutputToText(event.message); + } + return result; + }, + async return(value?: unknown) { + return iterator.return?.(value) ?? { done: true as const, value: undefined }; + }, + async throw(error?: unknown) { + return iterator.throw?.(error) ?? { done: true as const, value: undefined }; + }, + [Symbol.asyncIterator]() { + return this; + }, + }; + }; + return stream; +} + +/** @deprecated OpenAI-compatible provider stream helper; do not use from third-party plugins. */ +export function createThinkingOnlyFinalTextWrapper(params: { + baseStreamFn: StreamFn | undefined; + shouldPatchModel: (model: Parameters[0]) => boolean; +}): StreamFn | undefined { + if (!params.baseStreamFn) { + return undefined; + } + const underlying = params.baseStreamFn; + return (model, context, options) => { + const maybeStream = underlying(model, context, options); + if (!params.shouldPatchModel(model)) { + return maybeStream; + } + if (maybeStream && typeof maybeStream === "object" && "then" in maybeStream) { + return Promise.resolve(maybeStream).then((stream) => wrapThinkingOnlyFinalTextStream(stream)); + } + return wrapThinkingOnlyFinalTextStream(maybeStream); + }; +} + /** @deprecated Google provider-owned stream helper; do not use from third-party plugins. */ export type GoogleThinkingLevel = "MINIMAL" | "LOW" | "MEDIUM" | "HIGH"; /** @deprecated Google provider-owned stream helper; do not use from third-party plugins. */