Skip to content

[Provider compatibility] command-code/mimo-v2.6-pro: tool-call markup after prose in the same text block bypasses CommandCodeToolTextFilter #5698

Closed

Description

@marciodps

Client or integration

Codex CLI

Provider or upstream service

command-code and opencode-go, both routing Xiaomi MiMo models (xiaomi/mimo-v2.6-pro, mimo-v2.6-pro, mimo-v2.6-flash)

OpenCodex version

2.64.0

Endpoint or capability

Tool calls over streaming (/v1/responses bridged to the provider adapters); MiMo native tool-call markup reaching the assistant text channel

Current behaviour

When the gateway cannot translate MiMo's native tool-call envelope into a structured call, the envelope reaches the client as visible assistant text. Two distinct gaps:

  1. command-code adapter: CommandCodeToolTextFilter only holds a text block that opens with <tool_call>. The gateway echo can arrive after ordinary prose inside the same text block, so the probe mismatches on the first prose character, the block commits as ordinary text, and the markup streams to the client verbatim. A native tool-call carrying the same input follows, so the call executes and the raw markup also renders — a duplicate in the content channel (same shape as [Provider compatibility] opencode-go/mimo-v2.6: native XML tool-call envelope reaches visible assistant text #5499).

  2. openai-chat adapter (used by opencode-go): no tool-text handling at all, so the same echo always reaches the client (see [Provider compatibility] opencode-go/mimo-v2.6: native XML tool-call envelope reaches visible assistant text #5499).

Observed echo shapes: the complete envelope in a single text-delta after prose, and the variant that omits </function> (<tool_call><function=exec>RAW</parameter></tool_call>).

Expected behaviour

A recognized <tool_call> envelope is either consumed into the structured call or stripped from the content channel. It must never reach the client as assistant text, on any adapter that serves MiMo models.

Minimal redacted request or reproduction

Stream shape observed (simplified):

text-start
text-delta   "ordinary prose..."            <- probe mismatches, block commits as text
text-delta   "<tool_call><function=exec>…</parameter></function></tool_call>"
tool-call    (native, same input as the markup)
finish       stop / tool_calls

Any request that leads the MiMo gateway to echo the envelope (for example the Codex freeform exec custom tool lowered to a single input string parameter) reproduces it.

Actual response or error

The client renders the raw envelope as assistant text while the native call also executes:

...ordinary prose...<tool_call><function=exec>const results = await Promise.all([
  tools.exec_command({ cmd: "..." })
]);
...</parameter></function></tool_call>

Upstream documentation

No public specification exists for the command-code /alpha/generate stream or the opencode-go gateway; the MiMo envelope grammar is the one described in the vLLM and SGLang MiMo tool parsers (<tool_call><function=NAME><parameter=KEY>VALUE</parameter></function></tool_call>, freeform bodies raw without parameter tags). Cross-reference: #5499.

Suggested mapping or implementation notes

Validated local patch on 2.64.0 (both adapters):

  1. CommandCodeToolTextFilter.textDelta (command-code-tool-text.ts): scan deltas for TOOL_CALL_MARKER whenever the block state is not probing/held. Split at the marker (index > 0, or index 0 on a non-probing block): prose keeps its queued/streamed path, the markup starts a fresh probing block and follows the normal hold/drop/restore route. Extracted the incremental probe into a helper reused by both paths. Known limitation: a marker split across deltas after prose is still released as text (the gateway echoes the whole envelope in one delta in practice).

  2. openai-chat.ts: for MiMo-routed requests (/(^|\/)mimo-v2/i on the routed model id), content and reasoning deltas route through the same filter. Two wire differences mattered: the chat stream has no text-start/text-end events, so the open content block must be ended explicitly before the native-call flush (otherwise the filter interrupts the held block instead of matching it), and stopReasonFor() here never returns stop/tool_calls, so the restore verdict reads the raw finish_reason.

Validated with an SSE replay against the real filter/adapter: prose+markup in separate deltas with a native duplicate (dropped, no leak), prose+markup clean stop (restored), prose+markup in one delta (restored), pure-markup block (restored), plain prose (unchanged), non-MiMo model (gate off, passthrough), truncated stream (released as text). Happy to open a PR.

Additional context and attachments

Checks

  • I searched existing provider and compatibility issues.
  • The request and response were redacted.
  • The expected behaviour is based on an upstream specification or a concrete client requirement.

Activity

  1. added
    providerProvider adapters, OpenAI-compat presets, upstream API quirks
    toolstool_calls, MCP, web-search / sidecar tools
    on Sep 23, 2026
  2. github-actions commented on Sep 23, 2026

    @github-actions
    Contributor

    Issue reopened

    The report now contains the information required by the automated check. Thanks for updating it.

  3. marciodps commented on Sep 23, 2026

    @marciodps
    ContributorAuthor

    Update: applied the same approach locally to the openai-chat adapter (the one opencode-go uses), which had no tool-text handling at all — matching the report in #5499. For MiMo-routed requests (/(^|\/)mimo-v2/i on the routed model id), content and reasoning deltas route through CommandCodeToolTextFilter: markup is split off, held, dropped on a matching native call or restored on a clean stop/tool_calls finish. Two wire differences mattered: the chat stream has no text-start/text-end events, so the open content block must be ended explicitly before the native-call flush (otherwise the filter interrupts the held block instead of matching it), and stopReasonFor() here never returns stop/tool_calls, so the restore verdict has to read the raw finish_reason. Validated against the real adapter with an SSE replay: prose+markup+native duplicate (dropped, no leak), prose+markup clean stop (restored), plain prose (unchanged), non-MiMo model (gate off, passthrough), truncated stream (released as text).

  4. marciodps commented on Sep 23, 2026

    @marciodps
    ContributorAuthor

    Both patches against 2.64.0, applied with patch -p1 from the package root. These are running in production here (same validation matrix as described above).

    src/adapters/command-code-tool-text.ts — mid-prose marker split in textDelta, shared probe helper, queued-prose helper:

    Patch (125 lines)
    --- a/src/adapters/command-code-tool-text.ts	1985-10-26 08:15:00.000000000 +0000
    +++ b/src/adapters/command-code-tool-text.ts	2026-09-23 16:44:43.174259936 +0100
    @@ -22,6 +22,9 @@
      * A text block that opens with `<tool_call>` is therefore held instead of streamed. It is dropped
      * when a native call proves it is a duplicate, or restored on an eligible clean MiMo finish when
      * it names a declared tool with arguments that fit its schema. Other markup is released unchanged.
    + * MiMo can also append the markup after ordinary prose inside one text block; the stream filter
    + * splits such a delta at the marker and holds the markup part the same way (local fix 2026-09-23;
    + * a marker split across deltas after prose is still released as text).
      * Later text waits behind unresolved markup within the same byte bound.
      */
     
    @@ -310,8 +313,40 @@
           block = { id: key, markupParts: [], probe: "", bytes: 0, state: "probing", ended: false, interrupted: false, candidates: new Set(this.openInputs.keys()) };
           this.blocks.set(key, block);
         }
    -    if (block.state === "streaming" && this.head === this.pending.length) {
    -      return [...boundaryEvents, { type: "text_delta", text }];
    +    // MiMo can append tool-call markup after ordinary prose inside one text block. The probe
    +    // below only recognizes a block that opens with the marker, so a marker arriving after
    +    // prose would leak to the client (captured 2026-09-23 from xiaomi/mimo-v2.6-pro: prose,
    +    // then "<tool_call><function=exec>..." echoed by the gateway as one text delta). Split the
    +    // delta at the marker: prose keeps its queued or streamed path, the markup starts a fresh
    +    // probe block and follows the normal hold-and-restore route.
    +    if (block.state !== "held") {
    +      const markerIndex = text.indexOf(TOOL_CALL_MARKER);
    +      // markerIndex === 0 on a probing block is the ordinary hold path; on any other state the
    +      // block is ordinary text and the marker must still start a fresh probe block.
    +      if (markerIndex > 0 || (markerIndex === 0 && block.state !== "probing")) {
    +        const prose = markerIndex > 0 ? text.slice(0, markerIndex) : "";
    +        const marked = markerIndex > 0 ? text.slice(markerIndex) : text;
    +        let proseEvents: AdapterEvent[] = [];
    +        if (prose) {
    +          if (block.state === "streaming" && this.head === this.pending.length) {
    +            proseEvents = [{ type: "text_delta", text: prose }];
    +          } else {
    +            if (block.state === "dropped" || block.state === "streaming") {
    +              block = { id: key, markupParts: [], probe: "", bytes: 0, state: "queued", ended: false, interrupted: true, candidates: new Set() };
    +              this.blocks.set(key, block);
    +            }
    +            proseEvents = this.queueProseDelta(block, prose);
    +            this.probeBlockText(block, prose);
    +          }
    +        }
    +        if (block.state === "queued") block.state = "streaming";
    +        const probeBlock: TextBlock = { id: key, markupParts: [], probe: "", bytes: 0, state: "probing", ended: false, interrupted: false, candidates: new Set(this.openInputs.keys()) };
    +        this.blocks.set(key, probeBlock);
    +        return [...boundaryEvents, ...proseEvents, ...this.textDelta(id, marked)];
    +      }
    +      if (markerIndex === -1 && block.state === "streaming" && this.head === this.pending.length) {
    +        return [...boundaryEvents, { type: "text_delta", text }];
    +      }
         }
         // Once a duplicate is dropped, later text is a new chunk at its own wire position.
         if (block.state === "dropped" || block.state === "streaming") {
    @@ -332,23 +367,7 @@
           this.queueOperations++;
         }
         this.queuedBytes += bytes;
    -    if (block.state === "probing") {
    -      for (const char of text) {
    -        if (!block.probe && char.trim() === "") continue;
    -        block.probe += char;
    -        if (!TOOL_CALL_MARKER.startsWith(block.probe)) {
    -          block.state = "queued";
    -          block.markupParts = [];
    -          break;
    -        }
    -        if (block.probe === TOOL_CALL_MARKER) {
    -          block.state = "held";
    -          block.probe = "";
    -          this.held.push(block);
    -          break;
    -        }
    -      }
    -    }
    +    this.probeBlockText(block, text);
         if (block.state !== "probing" && block.state !== "held") this.activeProbes.delete(key);
         return [...boundaryEvents, ...preceding, ...this.limitPending()];
       }
    @@ -501,6 +520,44 @@
         block.bytes += bytes;
       }
     
    +  /** The incremental open-of-block probe: decide whether the block's text is tool-call markup. */
    +  private probeBlockText(block: TextBlock, text: string): void {
    +    if (block.state !== "probing") return;
    +    for (const char of text) {
    +      if (!block.probe && char.trim() === "") continue;
    +      block.probe += char;
    +      if (!TOOL_CALL_MARKER.startsWith(block.probe)) {
    +        block.state = "queued";
    +        block.markupParts = [];
    +        break;
    +      }
    +      if (block.probe === TOOL_CALL_MARKER) {
    +        block.state = "held";
    +        block.probe = "";
    +        this.held.push(block);
    +        break;
    +      }
    +    }
    +  }
    +
    +  /** Route ordinary prose through the queued wire path (shared by the mid-stream marker split). */
    +  private queueProseDelta(block: TextBlock, prose: string): AdapterEvent[] {
    +    const preceding = this.makeRoom(encoder.encode(prose).byteLength);
    +    this.retain(block, prose);
    +    const bytes = encoder.encode(prose).byteLength;
    +    const tail = this.pending.at(-1);
    +    if (tail?.kind === "chunk" && tail.block === block && this.head < this.pending.length) {
    +      tail.parts.push(prose);
    +      tail.bytes += bytes;
    +      this.queueOperations++;
    +    } else {
    +      this.pending.push({ kind: "chunk", block, parts: [prose], bytes });
    +      this.queueOperations++;
    +    }
    +    this.queuedBytes += bytes;
    +    return preceding;
    +  }
    +
       private drop(block: TextBlock): void {
         this.budget.releaseRetained(block.bytes, { kind: "live_transient" });
         this.queuedBytes = Math.max(0, this.queuedBytes - block.bytes);

    src/adapters/openai-chat.ts — MiMo gate in buildRequest, filter wiring in parseStream (content/reasoning routing, native-call matching with explicit block end, finish verdict on raw finish_reason):

    Patch (178 lines)
    --- a/src/adapters/openai-chat.ts	1985-10-26 08:15:00.000000000 +0000
    +++ b/src/adapters/openai-chat.ts	2026-09-23 17:57:18.755011821 +0100
    @@ -3,7 +3,8 @@
     import { applyExplicitChatReasoningWirePolicy } from "./openai-chat/reasoning-wire";
     import type { AdapterRequest, IncomingMeta, ProviderAdapter } from "./base";
     import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig, OcxUsage } from "../types";
    -import { modelInList } from "../types";
    +import { modelInList, namespacedToolName } from "../types";
    +import { CommandCodeToolTextFilter, type CommandCodeDeclaredTools } from "./command-code-tool-text";
     import { createInlineThinkContentSplitter, splitInlineThinkContent } from "./inline-think-tags";
     import { mapReasoningEffort, modelRecordValue } from "../reasoning-effort";
     import { debugProviderDiagnostic } from "../lib/debug";
    @@ -92,6 +93,10 @@
     
     export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAdapter {
       let lastRequestedModelId: string | undefined;
    +  // MiMo gateways echo the native <tool_call> envelope as content text (#5499, #5698). The
    +  // routed-model gate and the declared-tool map feed the shared tool-text filter in parseStream.
    +  let restoreMiMoTools = false;
    +  let lastDeclaredTools: CommandCodeDeclaredTools | undefined;
       return withOpenAIChatToolNames(toolNames => ({
         name: "openai-chat",
     
    @@ -99,6 +104,13 @@
     
         buildRequest(parsed: OcxParsedRequest, incoming?: IncomingMeta) {
           lastRequestedModelId = parsed.modelId;
    +      restoreMiMoTools = /(^|\/)mimo-v2/i.test(parsed.modelId);
    +      lastDeclaredTools = restoreMiMoTools
    +        ? new Map((parsed.context.tools ?? []).map(tool => [
    +            namespacedToolName(tool.namespace, tool.name),
    +            { freeform: tool.freeform === true, schema: tool.parameters },
    +          ]))
    +        : undefined;
           const { url, headers, hasCredential } = openAIChatTransport(provider);
           const messages = toolNames.messages(parsed, provider.baseUrl, messagesToChatFormat(parsed, provider));
           const finish = (): AdapterRequest => {
    @@ -332,16 +344,75 @@
                 return "terminate";
               }
               if (!call.id) call.id = `call_${++toolCallSeq}`;
    +          yield* emitNative(call);
    +        }
    +        return "continue";
    +      };
    +      // MiMo gateways can echo the native <tool_call> envelope as content text (#5499, #5698).
    +      // When the routed model is MiMo, content and reasoning route through the same tool-text
    +      // filter the command-code adapter uses: markup following prose inside one text block is
    +      // split off, held, then dropped on a matching native call or restored on a clean finish.
    +      // Other models pass straight through with no buffering.
    +      const toolText = restoreMiMoTools ? new CommandCodeToolTextFilter(budget, lastDeclaredTools) : undefined;
    +      const routeThroughToolText = function* (events: AdapterEvent[]): Generator<AdapterEvent> {
    +        if (!toolText) { yield* emitContent(events); return; }
    +        for (const event of events) {
    +          if (event.type === "text_delta") {
    +            yield* emitContent(toolText.textDelta(undefined, event.text));
    +          } else if (event.type === "reasoning_raw_delta") {
    +            yield* emitContent(toolText.enqueueEvent(event, event.text));
    +          } else {
    +            yield* emitContent(toolText.enqueueEvent(event, ""));
    +          }
    +        }
    +      };
    +      const emitNative = function* (call: PendingToolCall): Generator<AdapterEvent> {
    +        if (!toolText) {
               yield { type: "tool_call_start", id: call.id, name: toolNames.restore(call.name) };
               if (call.args.length > 0) yield { type: "tool_call_delta", arguments: call.args };
               yield { type: "tool_call_end" };
    +          return;
             }
    -        return "continue";
    +        // The chat wire has no text-start/text-end events, so the open content block must be
    +        // ended explicitly: otherwise nativeCall's open-block boundary would interrupt the held
    +        // markup (clearing it as interrupted text) instead of matching it against this call.
    +        yield* emitContent(toolText.textEnd(undefined));
    +        let input: unknown;
    +        try { input = JSON.parse(call.args) as unknown; } catch { input = call.args; }
    +        for (const event of toolText.nativeCall(call.key, call.name, input)) {
    +          if (event.type === "tool_call_start") {
    +            yield { type: "tool_call_start", id: event.id, name: toolNames.restore(event.name) };
    +          } else if (event.type === "tool_call_delta") {
    +            if (event.arguments.length > 0) yield event;
    +          } else {
    +            yield* emitContent([event]);
    +          }
    +        }
    +      };
    +      // Terminal verdict for held markup: restore as a call on a clean stop, release as text otherwise.
    +      const finishToolText = function* (): Generator<AdapterEvent, boolean> {
    +        if (!toolText) return false;
    +        // stopReasonFor() only maps length/content_filter here; the raw finish_reason is what
    +        // distinguishes a clean stop from a truncated or failed stream.
    +        const verdict = (finishReason === "stop" || finishReason === "tool_calls")
    +          ? toolText.finish()
    +          : { events: toolText.releaseAll(), salvaged: false };
    +        for (const event of verdict.events) {
    +          if (event.type === "tool_call_start") {
    +            yield { type: "tool_call_start", id: event.id, name: toolNames.restore(event.name) };
    +          } else if (event.type === "tool_call_delta") {
    +            if (event.arguments.length > 0) yield event;
    +          } else {
    +            yield* emitContent([event]);
    +          }
    +        }
    +        return verdict.salvaged;
           };
           const terminateWithError = function* (
             event: Extract<AdapterEvent, { type: "error" }>,
           ): Generator<AdapterEvent, "terminate"> {
             closeToolCalls();
    +        if (toolText) yield* emitContent(toolText.releaseAll());
             yield event;
             return "terminate";
           };
    @@ -369,8 +440,9 @@
             const payload = rawPayload.trim();
             if (payload.length === 0) return "continue";
             if (payload === "[DONE]") {
    -          yield* emitContent(inlineThink.flush());
    -          if ((yield* flushToolCalls()) === "terminate") return "terminate";
    +          yield* routeThroughToolText(inlineThink.flush());
    +          if ((yield* flushToolCalls()) === "terminate") { if (toolText) toolText.releaseAll(); return "terminate"; }
    +          yield* finishToolText();
               const stopReason = stopReasonFor(finishReason);
               yield { type: "done", usage: pendingUsage, ...(stopReason ? { stopReason } : {}) };
               return "terminate";
    @@ -423,14 +495,14 @@
               if (detailSegments.length > 0) {
                 for (const segment of detailSegments) {
                   const reasoningDelta = reasoningDetailTracker.ingest(segment);
    -              if (reasoningDelta !== null) yield { type: "reasoning_raw_delta", text: reasoningDelta };
    +              if (reasoningDelta !== null) yield* routeThroughToolText([{ type: "reasoning_raw_delta", text: reasoningDelta }]);
                 }
               } else {
                 const reasoningText = reasoningTextFrom(delta);
    -            if (reasoningText !== undefined) yield { type: "reasoning_raw_delta", text: reasoningText };
    +            if (reasoningText !== undefined) yield* routeThroughToolText([{ type: "reasoning_raw_delta", text: reasoningText }]);
               }
               if (typeof delta.content === "string" && delta.content.length > 0) {
    -            yield* emitContent(inlineThink.feed(delta.content));
    +            yield* routeThroughToolText(inlineThink.feed(delta.content));
               }
     
               const rawToolCalls = delta.tool_calls;
    @@ -569,7 +641,7 @@
             }
     
             if (typeof choice.finish_reason === "string" && choice.finish_reason) {
    -          yield* emitContent(inlineThink.flush());
    +          yield* routeThroughToolText(inlineThink.flush());
               if ((yield* flushToolCalls()) === "terminate") return "terminate";
             }
             return "continue";
    @@ -613,7 +685,7 @@
             if (buffer.length > 0) {
               if ((yield* handleDataLine(buffer)) === "terminate") return;
             }
    -        yield* emitContent(inlineThink.flush());
    +        yield* routeThroughToolText(inlineThink.flush());
             const sawFinish = finishReason !== undefined;
             if (!sawFinish && pendingToolCalls.length > 0) {
               // Some OpenAI-compatible gateways close immediately after a complete function-call
    @@ -622,6 +694,7 @@
               // complete JSON object. A partial JSON prefix still takes the truncation path below.
               if (provider.openaiChatEofTolerance === true && pendingToolCallsAreCompleteJsonObjects()) {
                 if ((yield* flushToolCalls()) === "terminate") return;
    +            yield* finishToolText();
                 yield { type: "done", usage: pendingUsage };
                 return;
               }
    @@ -642,6 +715,7 @@
               return;
             }
             if ((yield* flushToolCalls()) === "terminate") return;
    +        yield* finishToolText();
             const stopReason = stopReasonFor(finishReason);
             yield { type: "done", usage: pendingUsage, ...(stopReason ? { stopReason } : {}) };
           } catch (error) {

    If you prefer, I can open a PR from a fork instead.

  5. marciodps commented on Sep 23, 2026

    @marciodps
    ContributorAuthor

    Follow-up with a production-only failure mode the earlier patch did not cover — observed live on 2.64.0+the patch below after deploying it: markup still leaked after interleaved stream events.

    Root cause: breakOpenBlocks() treats held blocks as interruptible. In production, reasoning deltas (and other event types routed through enqueueEvent/boundary) interleave between the echo block and its text-end. Each such event interrupts the held block: markupParts is cleared, the block is marked interrupted, and at settle it is released as text — the leak returns even though the block opened with the marker and parsed as a declared tool. Clean synthetic sequences (text block ended before anything else arrives) never hit this path, which is why the earlier validation missed it.

    Fix (10 lines in breakOpenBlocks): a held block whose accumulated markupParts already parse via parseToolCallMarkup and name a declared tool is skipped — not interrupted, not cleared. Unresolved markup (never-closing envelopes) is still interrupted and released as text, preserving the bounded-release behavior. Held blocks remain bounded by MAX_HELD_TOOL_TEXT_BYTES.

    Validation matrix now includes interleaving scenarios: markup + interleaved reasoning + clean stop (restored), markup + interleaved reasoning + native duplicate (dropped, no leak), malformed <const = " envelope variant with interleaved reasoning (restored), never-closing partial markup + interleaved reasoning (still released as text), plus all previous scenarios (plain prose, non-MiMo gate off, failed-turn release). All pass against the real filter class and the real openai-chat adapter with SSE replay.

    Updated patch against pristine 2.64.0:

    src/adapters/command-code-tool-text.ts (breakOpenBlocks change; earlier textDelta changes included)
    --- a/src/adapters/command-code-tool-text.ts	1985-10-26 08:15:00.000000000 +0000
    +++ b/src/adapters/command-code-tool-text.ts	2026-09-23 19:14:37.471514033 +0100
    @@ -22,6 +22,9 @@
      * A text block that opens with `<tool_call>` is therefore held instead of streamed. It is dropped
      * when a native call proves it is a duplicate, or restored on an eligible clean MiMo finish when
      * it names a declared tool with arguments that fit its schema. Other markup is released unchanged.
    + * MiMo can also append the markup after ordinary prose inside one text block; the stream filter
    + * splits such a delta at the marker and holds the markup part the same way (local fix 2026-09-23;
    + * a marker split across deltas after prose is still released as text).
      * Later text waits behind unresolved markup within the same byte bound.
      */
     
    @@ -283,6 +286,16 @@
         for (const [key, block] of this.activeProbes) {
           this.queueOperations++;
           if (key === exceptKey) continue;
    +      // A held block whose accumulated markup already parses as a declared tool is a complete
    +      // envelope, not unresolved markup: interleaved events (reasoning deltas, other blocks,
    +      // the native call itself) must not clear it, or the echoed call leaks as text. Interrupted
    +      // blocks are released as text at settle, which is exactly the production leak seen once
    +      // reasoning deltas interleave with the echo. Only unresolved markup is released here; held
    +      // blocks stay bounded by MAX_HELD_TOOL_TEXT_BYTES.
    +      if (block.state === "held") {
    +        const markup = parseToolCallMarkup(block.markupParts.join(""));
    +        if (markup && this.declared?.has(markup.name)) continue;
    +      }
           this.activeProbes.delete(key);
           block.interrupted = true;
           block.state = "queued";
    @@ -310,8 +323,40 @@
           block = { id: key, markupParts: [], probe: "", bytes: 0, state: "probing", ended: false, interrupted: false, candidates: new Set(this.openInputs.keys()) };
           this.blocks.set(key, block);
         }
    -    if (block.state === "streaming" && this.head === this.pending.length) {
    -      return [...boundaryEvents, { type: "text_delta", text }];
    +    // MiMo can append tool-call markup after ordinary prose inside one text block. The probe
    +    // below only recognizes a block that opens with the marker, so a marker arriving after
    +    // prose would leak to the client (captured 2026-09-23 from xiaomi/mimo-v2.6-pro: prose,
    +    // then "<tool_call><function=exec>..." echoed by the gateway as one text delta). Split the
    +    // delta at the marker: prose keeps its queued or streamed path, the markup starts a fresh
    +    // probe block and follows the normal hold-and-restore route.
    +    if (block.state !== "held") {
    +      const markerIndex = text.indexOf(TOOL_CALL_MARKER);
    +      // markerIndex === 0 on a probing block is the ordinary hold path; on any other state the
    +      // block is ordinary text and the marker must still start a fresh probe block.
    +      if (markerIndex > 0 || (markerIndex === 0 && block.state !== "probing")) {
    +        const prose = markerIndex > 0 ? text.slice(0, markerIndex) : "";
    +        const marked = markerIndex > 0 ? text.slice(markerIndex) : text;
    +        let proseEvents: AdapterEvent[] = [];
    +        if (prose) {
    +          if (block.state === "streaming" && this.head === this.pending.length) {
    +            proseEvents = [{ type: "text_delta", text: prose }];
    +          } else {
    +            if (block.state === "dropped" || block.state === "streaming") {
    +              block = { id: key, markupParts: [], probe: "", bytes: 0, state: "queued", ended: false, interrupted: true, candidates: new Set() };
    +              this.blocks.set(key, block);
    +            }
    +            proseEvents = this.queueProseDelta(block, prose);
    +            this.probeBlockText(block, prose);
    +          }
    +        }
    +        if (block.state === "queued") block.state = "streaming";
    +        const probeBlock: TextBlock = { id: key, markupParts: [], probe: "", bytes: 0, state: "probing", ended: false, interrupted: false, candidates: new Set(this.openInputs.keys()) };
    +        this.blocks.set(key, probeBlock);
    +        return [...boundaryEvents, ...proseEvents, ...this.textDelta(id, marked)];
    +      }
    +      if (markerIndex === -1 && block.state === "streaming" && this.head === this.pending.length) {
    +        return [...boundaryEvents, { type: "text_delta", text }];
    +      }
         }
         // Once a duplicate is dropped, later text is a new chunk at its own wire position.
         if (block.state === "dropped" || block.state === "streaming") {
    @@ -332,23 +377,7 @@
           this.queueOperations++;
         }
         this.queuedBytes += bytes;
    -    if (block.state === "probing") {
    -      for (const char of text) {
    -        if (!block.probe && char.trim() === "") continue;
    -        block.probe += char;
    -        if (!TOOL_CALL_MARKER.startsWith(block.probe)) {
    -          block.state = "queued";
    -          block.markupParts = [];
    -          break;
    -        }
    -        if (block.probe === TOOL_CALL_MARKER) {
    -          block.state = "held";
    -          block.probe = "";
    -          this.held.push(block);
    -          break;
    -        }
    -      }
    -    }
    +    this.probeBlockText(block, text);
         if (block.state !== "probing" && block.state !== "held") this.activeProbes.delete(key);
         return [...boundaryEvents, ...preceding, ...this.limitPending()];
       }
    @@ -501,6 +530,44 @@
         block.bytes += bytes;
       }
     
    +  /** The incremental open-of-block probe: decide whether the block's text is tool-call markup. */
    +  private probeBlockText(block: TextBlock, text: string): void {
    +    if (block.state !== "probing") return;
    +    for (const char of text) {
    +      if (!block.probe && char.trim() === "") continue;
    +      block.probe += char;
    +      if (!TOOL_CALL_MARKER.startsWith(block.probe)) {
    +        block.state = "queued";
    +        block.markupParts = [];
    +        break;
    +      }
    +      if (block.probe === TOOL_CALL_MARKER) {
    +        block.state = "held";
    +        block.probe = "";
    +        this.held.push(block);
    +        break;
    +      }
    +    }
    +  }
    +
    +  /** Route ordinary prose through the queued wire path (shared by the mid-stream marker split). */
    +  private queueProseDelta(block: TextBlock, prose: string): AdapterEvent[] {
    +    const preceding = this.makeRoom(encoder.encode(prose).byteLength);
    +    this.retain(block, prose);
    +    const bytes = encoder.encode(prose).byteLength;
    +    const tail = this.pending.at(-1);
    +    if (tail?.kind === "chunk" && tail.block === block && this.head < this.pending.length) {
    +      tail.parts.push(prose);
    +      tail.bytes += bytes;
    +      this.queueOperations++;
    +    } else {
    +      this.pending.push({ kind: "chunk", block, parts: [prose], bytes });
    +      this.queueOperations++;
    +    }
    +    this.queuedBytes += bytes;
    +    return preceding;
    +  }
    +
       private drop(block: TextBlock): void {
         this.budget.releaseRetained(block.bytes, { kind: "live_transient" });
         this.queuedBytes = Math.max(0, this.queuedBytes - block.bytes);

    The openai-chat.ts patch from the previous comment is unchanged by this follow-up.

  6. marciodps commented on Sep 23, 2026

    @marciodps
    ContributorAuthor

    Third follow-up: another production-only leak shape on 2.64.0+previous patches — the envelope can also arrive malformed, and there were two more release paths that streamed it.

    Shape captured live (command-code, xiaomi/mimo-v2.6-pro):

    <tool_call><function=exec><parameter= results = await Promise.all([
      tools.exec_command({ ... })
    ]);
    results.forEach((r, i) => text(...)); </tool_call>
    

    Note the malformed parameter tag: <parameter= with no key, no >, and no closing </parameter> — the body runs to </tool_call>. parseToolCallMarkup correctly rejects it, but the filter then released it as text through two paths:

    1. breakOpenBlocks(): interleaved reasoning deltas interrupted the held block before text-end (the earlier follow-up fixed this only for parseable envelopes).
    2. matchNative(): when the native duplicate call arrives and the strict parse fails, the block cannot match, so it was queued back as ordinary text.

    Fix against pristine 2.64.0 (supersedes the earlier diffs of this file):

    • breakOpenBlocks() no longer interrupts held blocks at all. A held block opens with the marker by construction, so it is an envelope echo with high confidence; it stays held until settle. Memory and wait stay bounded by the existing queued-byte cap (makeRoom → MAX_HELD_TOOL_TEXT_BYTES, 64 KiB), which flushes everything as text if a held envelope never resolves while the stream keeps producing.
    • New isLooseEnvelope(): opens with <tool_call>, ends with </tool_call>, names a declared function inside, no nested markers. Used in both release paths — settle() (clean finish) and matchNative() (native duplicate arrives) — to drop malformed envelopes instead of releasing them as text. Dropping (rather than restoring) avoids double execution alongside the native call; releasing would put the raw markup back on screen, which is the reported bug.

    Validation (10 scenarios against the real filter class + the real openai-chat adapter with SSE replay): the exact captured shape with interleaved reasoning and clean stop (dropped, prose intact, no restored call to avoid double execution), same shape + native duplicate (dropped, native passes, exactly one call), all previous scenarios (parseable envelopes restored/dropped, plain prose, non-MiMo gate off, failed-turn fail-open release, never-closing partial markup still released as text). All pass.

    src/adapters/command-code-tool-text.ts — full patch vs 2.64.0
    --- a/src/adapters/command-code-tool-text.ts	1985-10-26 08:15:00.000000000 +0000
    +++ b/src/adapters/command-code-tool-text.ts	2026-09-23 20:31:54.647875272 +0100
    @@ -22,6 +22,9 @@
      * A text block that opens with `<tool_call>` is therefore held instead of streamed. It is dropped
      * when a native call proves it is a duplicate, or restored on an eligible clean MiMo finish when
      * it names a declared tool with arguments that fit its schema. Other markup is released unchanged.
    + * MiMo can also append the markup after ordinary prose inside one text block; the stream filter
    + * splits such a delta at the marker and holds the markup part the same way (local fix 2026-09-23;
    + * a marker split across deltas after prose is still released as text).
      * Later text waits behind unresolved markup within the same byte bound.
      */
     
    @@ -81,6 +84,20 @@
       try { return JSON.parse(value); } catch { return undefined; }
     }
     
    +/**
    + * Whether a block reads as a complete envelope echo even though the strict parser rejected it —
    + * malformed parameter tags, a missing `</function>`, garbage inside the body. Opening and
    + * closing as an envelope with a known function name is enough to keep it off the client; the
    + * native duplicate call, when the gateway emits one, carries the canonical execution.
    + */
    +function isLooseEnvelope(text: string, declared: CommandCodeDeclaredTools | undefined): boolean {
    +  const trimmed = text.trim();
    +  if (!trimmed.startsWith(TOOL_CALL_MARKER) || !trimmed.endsWith("</tool_call>")) return false;
    +  if (trimmed.slice(TOOL_CALL_MARKER.length).includes(TOOL_CALL_MARKER)) return false;
    +  const fn = /<function=([^>\s]+)>/.exec(trimmed);
    +  return fn !== undefined && (declared?.has(fn[1]!) ?? false);
    +}
    +
     function deepEqual(left: unknown, right: unknown): boolean {
       if (Object.is(left, right)) return true;
       if (typeof left !== "object" || typeof right !== "object" || left === null || right === null) return false;
    @@ -283,6 +300,13 @@
         for (const [key, block] of this.activeProbes) {
           this.queueOperations++;
           if (key === exceptKey) continue;
    +      // Held blocks open with the marker by construction (the probe guarantees it). Interrupting
    +      // them on interleaved events — reasoning deltas, other blocks, the native call itself —
    +      // cleared complete and malformed envelopes alike and released the echoed call as text: the
    +      // production leak. Held blocks therefore stay held until settle, in arrival order; the
    +      // queued-byte bound (makeRoom) still caps memory and wait, flushing everything as text if
    +      // a held envelope never resolves while the stream keeps producing.
    +      if (block.state === "held") continue;
           this.activeProbes.delete(key);
           block.interrupted = true;
           block.state = "queued";
    @@ -310,8 +334,40 @@
           block = { id: key, markupParts: [], probe: "", bytes: 0, state: "probing", ended: false, interrupted: false, candidates: new Set(this.openInputs.keys()) };
           this.blocks.set(key, block);
         }
    -    if (block.state === "streaming" && this.head === this.pending.length) {
    -      return [...boundaryEvents, { type: "text_delta", text }];
    +    // MiMo can append tool-call markup after ordinary prose inside one text block. The probe
    +    // below only recognizes a block that opens with the marker, so a marker arriving after
    +    // prose would leak to the client (captured 2026-09-23 from xiaomi/mimo-v2.6-pro: prose,
    +    // then "<tool_call><function=exec>..." echoed by the gateway as one text delta). Split the
    +    // delta at the marker: prose keeps its queued or streamed path, the markup starts a fresh
    +    // probe block and follows the normal hold-and-restore route.
    +    if (block.state !== "held") {
    +      const markerIndex = text.indexOf(TOOL_CALL_MARKER);
    +      // markerIndex === 0 on a probing block is the ordinary hold path; on any other state the
    +      // block is ordinary text and the marker must still start a fresh probe block.
    +      if (markerIndex > 0 || (markerIndex === 0 && block.state !== "probing")) {
    +        const prose = markerIndex > 0 ? text.slice(0, markerIndex) : "";
    +        const marked = markerIndex > 0 ? text.slice(markerIndex) : text;
    +        let proseEvents: AdapterEvent[] = [];
    +        if (prose) {
    +          if (block.state === "streaming" && this.head === this.pending.length) {
    +            proseEvents = [{ type: "text_delta", text: prose }];
    +          } else {
    +            if (block.state === "dropped" || block.state === "streaming") {
    +              block = { id: key, markupParts: [], probe: "", bytes: 0, state: "queued", ended: false, interrupted: true, candidates: new Set() };
    +              this.blocks.set(key, block);
    +            }
    +            proseEvents = this.queueProseDelta(block, prose);
    +            this.probeBlockText(block, prose);
    +          }
    +        }
    +        if (block.state === "queued") block.state = "streaming";
    +        const probeBlock: TextBlock = { id: key, markupParts: [], probe: "", bytes: 0, state: "probing", ended: false, interrupted: false, candidates: new Set(this.openInputs.keys()) };
    +        this.blocks.set(key, probeBlock);
    +        return [...boundaryEvents, ...proseEvents, ...this.textDelta(id, marked)];
    +      }
    +      if (markerIndex === -1 && block.state === "streaming" && this.head === this.pending.length) {
    +        return [...boundaryEvents, { type: "text_delta", text }];
    +      }
         }
         // Once a duplicate is dropped, later text is a new chunk at its own wire position.
         if (block.state === "dropped" || block.state === "streaming") {
    @@ -332,23 +388,7 @@
           this.queueOperations++;
         }
         this.queuedBytes += bytes;
    -    if (block.state === "probing") {
    -      for (const char of text) {
    -        if (!block.probe && char.trim() === "") continue;
    -        block.probe += char;
    -        if (!TOOL_CALL_MARKER.startsWith(block.probe)) {
    -          block.state = "queued";
    -          block.markupParts = [];
    -          break;
    -        }
    -        if (block.probe === TOOL_CALL_MARKER) {
    -          block.state = "held";
    -          block.probe = "";
    -          this.held.push(block);
    -          break;
    -        }
    -      }
    -    }
    +    this.probeBlockText(block, text);
         if (block.state !== "probing" && block.state !== "held") this.activeProbes.delete(key);
         return [...boundaryEvents, ...preceding, ...this.limitPending()];
       }
    @@ -420,6 +460,14 @@
           }
           block.candidates.delete(id);
           if (block.candidates.size === 0) {
    +        // A malformed envelope cannot match a native input, but it is still an envelope: drop it
    +        // rather than releasing the echo as text (the native call carries the execution).
    +        if (markup === undefined && isLooseEnvelope(block.markupParts.join(""), this.declared)) {
    +          this.drop(block);
    +          block.state = "dropped";
    +          this.activeProbes.delete(block.id);
    +          continue;
    +        }
             block.state = "queued";
             block.markupParts = [];
             this.activeProbes.delete(block.id);
    @@ -472,6 +520,14 @@
               events.push({ type: "tool_call_end" });
               salvaged = true;
               lastTextBlock = undefined;
    +        } else if (restore && isLooseEnvelope(block.markupParts.join(""), this.declared)) {
    +          // A malformed envelope is still an envelope: the native duplicate (observed in every
    +          // capture) carries the call, so the echo is dropped rather than rendered as text.
    +          // Releasing it would put the raw markup back on screen; restoring it could execute a
    +          // second time alongside the native call.
    +          this.drop(block);
    +          block.state = "dropped";
    +          lastTextBlock = undefined;
             } else {
               block.state = "queued";
               block.markupParts = [];
    @@ -501,6 +557,44 @@
         block.bytes += bytes;
       }
     
    +  /** The incremental open-of-block probe: decide whether the block's text is tool-call markup. */
    +  private probeBlockText(block: TextBlock, text: string): void {
    +    if (block.state !== "probing") return;
    +    for (const char of text) {
    +      if (!block.probe && char.trim() === "") continue;
    +      block.probe += char;
    +      if (!TOOL_CALL_MARKER.startsWith(block.probe)) {
    +        block.state = "queued";
    +        block.markupParts = [];
    +        break;
    +      }
    +      if (block.probe === TOOL_CALL_MARKER) {
    +        block.state = "held";
    +        block.probe = "";
    +        this.held.push(block);
    +        break;
    +      }
    +    }
    +  }
    +
    +  /** Route ordinary prose through the queued wire path (shared by the mid-stream marker split). */
    +  private queueProseDelta(block: TextBlock, prose: string): AdapterEvent[] {
    +    const preceding = this.makeRoom(encoder.encode(prose).byteLength);
    +    this.retain(block, prose);
    +    const bytes = encoder.encode(prose).byteLength;
    +    const tail = this.pending.at(-1);
    +    if (tail?.kind === "chunk" && tail.block === block && this.head < this.pending.length) {
    +      tail.parts.push(prose);
    +      tail.bytes += bytes;
    +      this.queueOperations++;
    +    } else {
    +      this.pending.push({ kind: "chunk", block, parts: [prose], bytes });
    +      this.queueOperations++;
    +    }
    +    this.queuedBytes += bytes;
    +    return preceding;
    +  }
    +
       private drop(block: TextBlock): void {
         this.budget.releaseRetained(block.bytes, { kind: "live_transient" });
         this.queuedBytes = Math.max(0, this.queuedBytes - block.bytes);
  7. lidge-jun commented on Sep 24, 2026

    @lidge-jun
    Owner

    Fixed on dev by bundle #5739 (squash-merged as a8d526f); it will ship in the next release. Closing manually because dev is not the default branch, so GitHub does not auto-close. If you still see this on a build that includes a8d526f, please reopen with the version and a redacted reproduction.

  8. marciodps commented on Sep 24, 2026

    @marciodps
    ContributorAuthor

    Thanks for the quick turnaround. The dev implementation matches what we validated locally (prose-split, held blocks left uninterruptible, loose-envelope drop keyed on the native duplicate), and the fence-aware shared module plus streaming bounds go beyond it. I will run our repro matrix against the first release that includes a8d526f and reopen with the version and a redacted reproduction if anything slips through.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    providerProvider adapters, OpenAI-compat presets, upstream API quirksprovider-compatibilityProvider compatibility reportstoolstool_calls, MCP, web-search / sidecar tools

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions