From 9e4d63cce52ee2b6e0f01a6a61078aadeeb7f42c Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 11:34:45 +0900 Subject: [PATCH 1/4] fix(openai-chat): read MiMo tool-call echoes without or with a header newline The Chat reconciler removed a duplicated block only in the canonical BODY shape. MiMo also echoes the block without , and sometimes with a template newline after the function header; both stayed on screen next to the structured call that ran (#5724). The block pattern now accepts the unclosed form after trying the closed one, and the body comparison drops one leading newline, matching the Command Code reader's grammar. Suppression still requires the name and body to agree with a structured call, so mismatched markup stays visible. --- .../serialized-tool-call-content.ts | 36 +++++++++--- .../ADR-5548-serialized-tool-call-content.md | 1 + structure/providers/chat-compat.md | 6 +- ...-chat-serialized-tool-call-content.test.ts | 57 ++++++++++++++++++- 4 files changed, 90 insertions(+), 10 deletions(-) diff --git a/src/adapters/openai-chat/serialized-tool-call-content.ts b/src/adapters/openai-chat/serialized-tool-call-content.ts index 5bcd579df9f..f084e9501a0 100644 --- a/src/adapters/openai-chat/serialized-tool-call-content.ts +++ b/src/adapters/openai-chat/serialized-tool-call-content.ts @@ -32,17 +32,32 @@ export interface StructuredToolCallReference { argumentsText: string; } +const CLOSED_BLOCK = /\s*\r\n]+)>([\s\S]*?)(?:<\/parameter>)?\s*<\/function>\s*<\/tool_call>/y; +const UNCLOSED_FUNCTION_BLOCK = /\s*\r\n]+)>([\s\S]*?)(?:<\/parameter>)?\s*<\/tool_call>/y; + +/** + * The block starting at `offset`. MiMo's echo may close a freeform body with a stray `` + * and may omit `` (#5724), the grammar the Command Code reader accepts too. The closed + * form is tried first so a body can still carry a literal ``; a closed match that + * swallowed a second opening tag belongs to two blocks, so the unclosed form reads the first. + */ +function blockAt(text: string, offset: number): RegExpExecArray | null { + CLOSED_BLOCK.lastIndex = offset; + const closed = CLOSED_BLOCK.exec(text); + if (closed && !closed[2]!.includes(OPEN_TAG)) return closed; + UNCLOSED_FUNCTION_BLOCK.lastIndex = offset; + return UNCLOSED_FUNCTION_BLOCK.exec(text); +} + /** Finds complete bare blocks outside literal Markdown; ambiguous outer blocks stop the scan. */ function callsIn(text: string, context: TextContext = { fence: null, lineStart: true }): SerializedToolCall[] { - const pattern = /\s*\r\n]+)>([\s\S]*?)(?:<\/parameter>)?\s*<\/function>\s*<\/tool_call>/y; const calls: SerializedToolCall[] = []; let offset = 0; while (offset < text.length) { const split = splitAtPossibleSerializedToolCall(text.slice(offset), context, true); offset += split.emit.length; if (!split.hasOpenTag) break; - pattern.lastIndex = offset; - const match = pattern.exec(text); + const match = blockAt(text, offset); if (!match) break; // An incomplete/ambiguous outer block cannot authorize an inner call. calls.push({ name: match[1]!.trim(), @@ -50,7 +65,7 @@ function callsIn(text: string, context: TextContext = { fence: null, lineStart: start: match.index, end: match.index + match[0].length, }); - offset = pattern.lastIndex; + offset = match.index + match[0].length; context = { fence: null, lineStart: false }; } return calls; @@ -279,6 +294,11 @@ function inputFromArguments(argumentsText: string): string | undefined { } } +/** One wrapping newline after the function header is template layout, not input (vLLM `_trim_wrapping_newlines`). */ +function freeformBody(value: string): string { + return value.replace(/^\r?\n/, "").trimEnd(); +} + /** The `[start, end)` ranges of blocks whose function identity and freeform input match a dispatched call. */ function duplicatedSerializedToolCallRanges( text: string, @@ -287,9 +307,11 @@ function duplicatedSerializedToolCallRanges( ): { start: number; end: number }[] { if (structuredCalls.length === 0) return []; return callsIn(text, context).filter(call => { - const body = call.body.trimEnd(); - return structuredCalls.some(structured => - structured.names.has(call.name) && inputFromArguments(structured.argumentsText)?.trimEnd() === body); + const body = freeformBody(call.body); + return structuredCalls.some(structured => { + const input = structured.names.has(call.name) ? inputFromArguments(structured.argumentsText) : undefined; + return input !== undefined && freeformBody(input) === body; + }); }); } diff --git a/structure/decisions/ADR-5548-serialized-tool-call-content.md b/structure/decisions/ADR-5548-serialized-tool-call-content.md index 13b2a1c31d0..abdc8f08632 100644 --- a/structure/decisions/ADR-5548-serialized-tool-call-content.md +++ b/structure/decisions/ADR-5548-serialized-tool-call-content.md @@ -11,3 +11,4 @@ - Why: Agreement between both representations is deterministic and avoids changing ordinary commentary, mismatched markup, or unrelated providers' valid text. - Consequences: Matching calls no longer appear twice; same-name/different-body examples remain visible; the small held region is translator-budgeted and emits heartbeats while held; terminal failures retain held text without dispatching tools; malformed concatenated arguments are repaired only for the exact duplicated wrapper shape. - Follow-up (260924): the streaming hold is bounded (8 KiB of prose after a closed block, 4 MiB total); past a bound held text is released unsuppressed. See structure/providers/chat-compat.md. +- Follow-up (#5724): MiMo echoes that omit `` or put a template newline after the function header are read as the same block, matching the Command Code reader's grammar; suppression still requires the name and body to agree with a structured call. diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index b6ebdcef87e..d37174c589f 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -325,7 +325,11 @@ entry. `src/adapters/openai-chat/serialized-tool-call-content.ts` recognizes bar start of a line outside Markdown fences; inline, quoted and indented examples remain unchanged. It holds a possible serialized block, resumes ordinary text delivery when the header cannot match, and removes the block only when its function name and -freeform body match a structured call's parsed `input` in the same response. If the gateway also prefixes the structured call's JSON +freeform body match a structured call's parsed `input` in the same response. +A block may close a freeform body with a stray `` and may omit ``, and one +newline after the function header is template layout, so MiMo's echoes of those shapes match too +(#5724); the closed form is tried first so a body can still carry a literal ``. +If the gateway also prefixes the structured call's JSON arguments with the same freeform body, the adapter keeps the JSON suffix only when the block body, prefix, and wrapper's `input` value all agree. Mismatched markup and arguments remain byte-exact. Silent held-content frames emit adapter heartbeats. Terminal errors and transport read failures diff --git a/tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts b/tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts index e1e64287dfb..703ecac894d 100644 --- a/tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts +++ b/tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts @@ -1,7 +1,8 @@ -import { expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { createOpenAIChatAdapter } from "../../../src/adapters/openai-chat"; import { SerializedToolCallContentBuffer } from "../../../src/adapters/openai-chat/serialized-tool-call-content"; -import { createTestTranslatorBudget } from "../../helpers/translator-budget"; +import type { AdapterEvent } from "../../../src/types"; +import { createTestTranslatorBudget, withTestTranslatorBudget } from "../../helpers/translator-budget"; const provider = { adapter: "openai-chat", baseUrl: "https://openrouter.ai/api/v1", apiKey: "key" } as const; @@ -61,3 +62,55 @@ test("an open serialized block charges only its appended bytes", () => { expect(buffer.flush([])).toBe(open + body); expect(budget.snapshot()).toMatchObject({ currentBytes: 0, overflows: 0 }); }); +describe("MiMo echo variants (#5724)", () => { + const script = 'const r = await tools.exec_command({cmd:"Get-Content a.txt"}); text(r.output);'; + const call = (input: string) => ({ index: 0, id: "call_exec", function: { name: "exec", arguments: JSON.stringify({ input }) } }); + + async function streamed(content: string, input: string): Promise { + const adapter = withTestTranslatorBudget(createOpenAIChatAdapter(provider)); + adapter.buildRequest({ modelId: "mimo-v2.6-pro", stream: true, options: {}, context: { messages: [{ role: "user", content: "ping", timestamp: 0 }] } }); + const frames = [ + { choices: [{ delta: { content: content.slice(0, 30) } }] }, + { choices: [{ delta: { content: content.slice(30) } }] }, + { choices: [{ delta: { tool_calls: [call(input)] } }] }, + { choices: [{ delta: {}, finish_reason: "tool_calls" }] }, + ]; + const body = frames.map(frame => `data: ${JSON.stringify(frame)}\n\n`).join("") + "data: [DONE]\n\n"; + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream(new Response(body))) if (event.type !== "heartbeat") events.push(event); + return events; + } + async function buffered(content: string, input: string): Promise { + return createOpenAIChatAdapter(provider).parseResponse!(Response.json({ + choices: [{ message: { content, tool_calls: [call(input)] }, finish_reason: "tool_calls" }], + }), createTestTranslatorBudget()); + } + const visible = (events: AdapterEvent[]): string => events + .map(event => (event.type === "text_delta" ? event.text : "")) + .join(""); + + test.each([ + ["the header is followed by a template newline", `\n${script}\n`], + ["the echo omits ", `${script}`], + ])("a matching block is removed when %s", async (_label, block) => { + for (const events of [await streamed(`Reading.\n${block}`, script), await buffered(`Reading.\n${block}`, script)]) { + expect(visible(events)).toBe("Reading.\n"); + expect(events.filter(event => event.type === "tool_call_start")).toHaveLength(1); + } + }); + + test("an unclosed block with a different body stays visible", async () => { + const block = "text('other');"; + for (const events of [await streamed(block, script), await buffered(block, script)]) { + expect(visible(events)).toBe(block); + } + }); + + test("a closed block whose body carries a literal is still matched whole", async () => { + const input = "text('');"; + const block = `${input}`; + for (const events of [await streamed(block, input), await buffered(block, input)]) { + expect(visible(events)).toBe(""); + } + }); +}); From 7e97376efe8be2305d0a5bb0d2d22b159a2567a1 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 11:46:56 +0900 Subject: [PATCH 2/4] fix(openai-chat): scan serialized blocks linearly and record ADR-5724 Review follow-up. The closed/unclosed regex pair backtracked quadratically on a long unterminated body and hid a closed body that merely contained a literal . Blocks are now read by delimiter scan: the first preceded by closes the block, and only when none appears before the next real block header does the first close it. The decision moves to its own record, ADR-5724; ADR-5548 is left as it was. --- .../serialized-tool-call-content.ts | 63 +++++++++++++------ .../ADR-5548-serialized-tool-call-content.md | 1 - .../ADR-5724-serialized-tool-call-content.md | 13 ++++ structure/providers/chat-compat.md | 5 +- ...-chat-serialized-tool-call-content.test.ts | 23 +++++-- 5 files changed, 80 insertions(+), 25 deletions(-) create mode 100644 structure/decisions/ADR-5724-serialized-tool-call-content.md diff --git a/src/adapters/openai-chat/serialized-tool-call-content.ts b/src/adapters/openai-chat/serialized-tool-call-content.ts index f084e9501a0..17ebaac3c77 100644 --- a/src/adapters/openai-chat/serialized-tool-call-content.ts +++ b/src/adapters/openai-chat/serialized-tool-call-content.ts @@ -32,21 +32,51 @@ export interface StructuredToolCallReference { argumentsText: string; } -const CLOSED_BLOCK = /\s*\r\n]+)>([\s\S]*?)(?:<\/parameter>)?\s*<\/function>\s*<\/tool_call>/y; -const UNCLOSED_FUNCTION_BLOCK = /\s*\r\n]+)>([\s\S]*?)(?:<\/parameter>)?\s*<\/tool_call>/y; +const BLOCK_HEADER = /\s*\r\n]+)>/y; +const NEXT_BLOCK_HEADER = /\s*\r\n]+>/g; +const FUNCTION_CLOSE = ""; +const PARAMETER_CLOSE = ""; + +function trimmedEnd(text: string, from: number, to: number): number { + while (to > from && /\s/.test(text[to - 1]!)) to--; + return to; +} + +function endsWithAt(text: string, from: number, to: number, suffix: string): boolean { + return to - suffix.length >= from && text.startsWith(suffix, to - suffix.length); +} /** - * The block starting at `offset`. MiMo's echo may close a freeform body with a stray `` - * and may omit `` (#5724), the grammar the Command Code reader accepts too. The closed - * form is tried first so a body can still carry a literal ``; a closed match that - * swallowed a second opening tag belongs to two blocks, so the unclosed form reads the first. + * The block starting at `offset`, read by delimiter scan so an unterminated block costs linear time. + * MiMo's echo may close a freeform body with a stray `` and may omit `` + * (#5724), the grammar the Command Code reader accepts too. The first `` preceded by + * `` closes the block, so a body can still carry a literal `` or + * ``; with none before the next real block header, the first `` does. */ -function blockAt(text: string, offset: number): RegExpExecArray | null { - CLOSED_BLOCK.lastIndex = offset; - const closed = CLOSED_BLOCK.exec(text); - if (closed && !closed[2]!.includes(OPEN_TAG)) return closed; - UNCLOSED_FUNCTION_BLOCK.lastIndex = offset; - return UNCLOSED_FUNCTION_BLOCK.exec(text); +function blockAt(text: string, offset: number): SerializedToolCall | undefined { + BLOCK_HEADER.lastIndex = offset; + const header = BLOCK_HEADER.exec(text); + if (!header) return undefined; + const bodyStart = offset + header[0].length; + NEXT_BLOCK_HEADER.lastIndex = bodyStart; + const limit = NEXT_BLOCK_HEADER.exec(text)?.index ?? text.length; + let unclosed: SerializedToolCall | undefined; + for (let close = text.indexOf(CLOSE_TAG, bodyStart); close >= 0 && close < limit; + close = text.indexOf(CLOSE_TAG, close + CLOSE_TAG.length)) { + let bodyEnd = trimmedEnd(text, bodyStart, close); + const closed = endsWithAt(text, bodyStart, bodyEnd, FUNCTION_CLOSE); + if (closed) bodyEnd = trimmedEnd(text, bodyStart, bodyEnd - FUNCTION_CLOSE.length); + if (endsWithAt(text, bodyStart, bodyEnd, PARAMETER_CLOSE)) bodyEnd -= PARAMETER_CLOSE.length; + const call = { + name: header[1]!.trim(), + body: text.slice(bodyStart, bodyEnd), + start: offset, + end: close + CLOSE_TAG.length, + }; + if (closed) return call; + unclosed ??= call; + } + return unclosed; } /** Finds complete bare blocks outside literal Markdown; ambiguous outer blocks stop the scan. */ @@ -59,13 +89,8 @@ function callsIn(text: string, context: TextContext = { fence: null, lineStart: if (!split.hasOpenTag) break; const match = blockAt(text, offset); if (!match) break; // An incomplete/ambiguous outer block cannot authorize an inner call. - calls.push({ - name: match[1]!.trim(), - body: match[2]!, - start: match.index, - end: match.index + match[0].length, - }); - offset = match.index + match[0].length; + calls.push(match); + offset = match.end; context = { fence: null, lineStart: false }; } return calls; diff --git a/structure/decisions/ADR-5548-serialized-tool-call-content.md b/structure/decisions/ADR-5548-serialized-tool-call-content.md index abdc8f08632..13b2a1c31d0 100644 --- a/structure/decisions/ADR-5548-serialized-tool-call-content.md +++ b/structure/decisions/ADR-5548-serialized-tool-call-content.md @@ -11,4 +11,3 @@ - Why: Agreement between both representations is deterministic and avoids changing ordinary commentary, mismatched markup, or unrelated providers' valid text. - Consequences: Matching calls no longer appear twice; same-name/different-body examples remain visible; the small held region is translator-budgeted and emits heartbeats while held; terminal failures retain held text without dispatching tools; malformed concatenated arguments are repaired only for the exact duplicated wrapper shape. - Follow-up (260924): the streaming hold is bounded (8 KiB of prose after a closed block, 4 MiB total); past a bound held text is released unsuppressed. See structure/providers/chat-compat.md. -- Follow-up (#5724): MiMo echoes that omit `` or put a template newline after the function header are read as the same block, matching the Command Code reader's grammar; suppression still requires the name and body to agree with a structured call. diff --git a/structure/decisions/ADR-5724-serialized-tool-call-content.md b/structure/decisions/ADR-5724-serialized-tool-call-content.md new file mode 100644 index 00000000000..a2990dfa9e4 --- /dev/null +++ b/structure/decisions/ADR-5724-serialized-tool-call-content.md @@ -0,0 +1,13 @@ +# ADR-5724 — decision recorded under "Serialized tool-call content" + +- Contract owner: [providers/chat-compat.md](../providers/chat-compat.md#serialized-tool-call-content) + +## Decision record + +- Intent: Remove MiMo's duplicated tool-call echo on OpenAI Chat routes when it arrives in the shapes the model actually emits, not only the canonical one (#5724). +- Prior constraint: ADR-5548 suppresses a block only when its function name and freeform body agree with a structured call in the same response; mismatched markup stays byte-exact. +- Observed shapes: MiMo's echo can omit `` (`BODY`) and can put a template newline after the function header. The Command Code reader already accepts both (#5637); the Chat reconciler did not, so those echoes stayed visible beside the call that ran. +- Alternatives considered: Keep two regular expressions and try the closed form first (backtracks quadratically on a long unterminated body, and rejecting a closed match on any inner `` hides a body that merely contains that string); drop anything shaped like a tool call (discards real text); restore calls from markup when no structured call exists (a new behaviour this route has no evidence for). +- Choice: Read each block by delimiter scan. The first `` preceded by `` closes the block; if none appears before the next real block header (`` followed by `` does. A stray `` before the close is markup, and one leading newline in the body is template layout. +- Why: It accepts the same grammar on both MiMo routes, keeps a body that contains a literal `` or `` intact, and costs linear time. The agreement rule from ADR-5548 is unchanged, so no new text can disappear without a matching structured call. +- Consequences: The two echo shapes are removed when they duplicate a structured call, streamed and buffered. Markup with no structured call is still shown and still runs nothing. diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index d37174c589f..2a97f5a46d7 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -328,7 +328,9 @@ and removes the block only when its function name and freeform body match a structured call's parsed `input` in the same response. A block may close a freeform body with a stray `` and may omit ``, and one newline after the function header is template layout, so MiMo's echoes of those shapes match too -(#5724); the closed form is tried first so a body can still carry a literal ``. +(#5724). Blocks are read by delimiter scan in linear time: the first `` preceded by +`` closes the block, and only when none appears before the next real block header does +the first `` close it, so a body can still carry a literal `` or ``. If the gateway also prefixes the structured call's JSON arguments with the same freeform body, the adapter keeps the JSON suffix only when the block body, prefix, and wrapper's `input` value all agree. Mismatched markup and arguments remain byte-exact. @@ -343,6 +345,7 @@ matching and repair rules; regression coverage enters through `/v1/responses` in `tests/responses/responses-chat-tool-call-content.test.ts`. > Decision record: [ADR-5548](../decisions/ADR-5548-serialized-tool-call-content.md) +> Decision record: [ADR-5724](../decisions/ADR-5724-serialized-tool-call-content.md) ## Kimi Coding Plan prompt-cache affinity diff --git a/tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts b/tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts index 703ecac894d..e254e7a79fb 100644 --- a/tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts +++ b/tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts @@ -107,10 +107,25 @@ describe("MiMo echo variants (#5724)", () => { }); test("a closed block whose body carries a literal is still matched whole", async () => { - const input = "text('');"; - const block = `${input}`; - for (const events of [await streamed(block, input), await buffered(block, input)]) { - expect(visible(events)).toBe(""); + for (const input of ["text('');", "text('');"]) { + const block = `${input}`; + for (const events of [await streamed(block, input), await buffered(block, input)]) { + expect(visible(events)).toBe(""); + } } }); + + test("an unclosed block followed by a closed block is read as two blocks", async () => { + const first = "text('a');"; + const second = "text('b');"; + const content = `${first}\n${second}`; + const events = await createOpenAIChatAdapter(provider).parseResponse!(Response.json({ + choices: [{ + message: { content, tool_calls: [call(first), { ...call(second), index: 1, id: "call_exec_2" }] }, + finish_reason: "tool_calls", + }], + }), createTestTranslatorBudget()); + expect(visible(events)).toBe("\n"); + expect(events.filter(event => event.type === "tool_call_start")).toHaveLength(2); + }); }); From 5634c4edde533fed5d868720f9e29c59c3b02534 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 11:55:21 +0900 Subject: [PATCH 3/4] fix(openai-chat): bound the block scan by line-start headers only A body can carry a full literal header such as text(""). A separate bare block can only begin at the start of a line, so the scan now stops only at a header there. --- .../openai-chat/serialized-tool-call-content.ts | 10 ++++++---- .../decisions/ADR-5724-serialized-tool-call-content.md | 4 ++-- structure/providers/chat-compat.md | 4 ++-- .../openai-chat-serialized-tool-call-content.test.ts | 4 ++-- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/adapters/openai-chat/serialized-tool-call-content.ts b/src/adapters/openai-chat/serialized-tool-call-content.ts index 17ebaac3c77..9b76a2450a3 100644 --- a/src/adapters/openai-chat/serialized-tool-call-content.ts +++ b/src/adapters/openai-chat/serialized-tool-call-content.ts @@ -33,7 +33,8 @@ export interface StructuredToolCallReference { } const BLOCK_HEADER = /\s*\r\n]+)>/y; -const NEXT_BLOCK_HEADER = /\s*\r\n]+>/g; +/** A separate bare block starts a line (see `splitAtPossibleSerializedToolCall`); a header mid-line is body text. */ +const NEXT_BLOCK_HEADER = /\n\s*\r\n]+>/g; const FUNCTION_CLOSE = ""; const PARAMETER_CLOSE = ""; @@ -50,8 +51,8 @@ function endsWithAt(text: string, from: number, to: number, suffix: string): boo * The block starting at `offset`, read by delimiter scan so an unterminated block costs linear time. * MiMo's echo may close a freeform body with a stray `` and may omit `` * (#5724), the grammar the Command Code reader accepts too. The first `` preceded by - * `` closes the block, so a body can still carry a literal `` or - * ``; with none before the next real block header, the first `` does. + * `` closes the block, so a body can still carry a literal `` or header; + * with none before the next line-start block header, the first `` does. */ function blockAt(text: string, offset: number): SerializedToolCall | undefined { BLOCK_HEADER.lastIndex = offset; @@ -59,7 +60,8 @@ function blockAt(text: string, offset: number): SerializedToolCall | undefined { if (!header) return undefined; const bodyStart = offset + header[0].length; NEXT_BLOCK_HEADER.lastIndex = bodyStart; - const limit = NEXT_BLOCK_HEADER.exec(text)?.index ?? text.length; + const next = NEXT_BLOCK_HEADER.exec(text); + const limit = next ? next.index + 1 : text.length; let unclosed: SerializedToolCall | undefined; for (let close = text.indexOf(CLOSE_TAG, bodyStart); close >= 0 && close < limit; close = text.indexOf(CLOSE_TAG, close + CLOSE_TAG.length)) { diff --git a/structure/decisions/ADR-5724-serialized-tool-call-content.md b/structure/decisions/ADR-5724-serialized-tool-call-content.md index a2990dfa9e4..34bb5bf1079 100644 --- a/structure/decisions/ADR-5724-serialized-tool-call-content.md +++ b/structure/decisions/ADR-5724-serialized-tool-call-content.md @@ -8,6 +8,6 @@ - Prior constraint: ADR-5548 suppresses a block only when its function name and freeform body agree with a structured call in the same response; mismatched markup stays byte-exact. - Observed shapes: MiMo's echo can omit `` (`BODY`) and can put a template newline after the function header. The Command Code reader already accepts both (#5637); the Chat reconciler did not, so those echoes stayed visible beside the call that ran. - Alternatives considered: Keep two regular expressions and try the closed form first (backtracks quadratically on a long unterminated body, and rejecting a closed match on any inner `` hides a body that merely contains that string); drop anything shaped like a tool call (discards real text); restore calls from markup when no structured call exists (a new behaviour this route has no evidence for). -- Choice: Read each block by delimiter scan. The first `` preceded by `` closes the block; if none appears before the next real block header (`` followed by `` does. A stray `` before the close is markup, and one leading newline in the body is template layout. -- Why: It accepts the same grammar on both MiMo routes, keeps a body that contains a literal `` or `` intact, and costs linear time. The agreement rule from ADR-5548 is unchanged, so no new text can disappear without a matching structured call. +- Choice: Read each block by delimiter scan. The first `` preceded by `` closes the block; if none appears before the next block header at the start of a line (`` followed by `` does. A stray `` before the close is markup, and one leading newline in the body is template layout. +- Why: It accepts the same grammar on both MiMo routes, keeps a body that contains literal tool-call tags (even a full header) intact, and costs linear time. The agreement rule from ADR-5548 is unchanged, so no new text can disappear without a matching structured call. - Consequences: The two echo shapes are removed when they duplicate a structured call, streamed and buffered. Markup with no structured call is still shown and still runs nothing. diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 2a97f5a46d7..46cb7064238 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -329,8 +329,8 @@ freeform body match a structured call's parsed `input` in the same response. A block may close a freeform body with a stray `` and may omit ``, and one newline after the function header is template layout, so MiMo's echoes of those shapes match too (#5724). Blocks are read by delimiter scan in linear time: the first `` preceded by -`` closes the block, and only when none appears before the next real block header does -the first `` close it, so a body can still carry a literal `` or ``. +`` closes the block, and only when none appears before the next block header at the start +of a line does the first `` close it, so a body can still carry literal tool-call tags. If the gateway also prefixes the structured call's JSON arguments with the same freeform body, the adapter keeps the JSON suffix only when the block body, prefix, and wrapper's `input` value all agree. Mismatched markup and arguments remain byte-exact. diff --git a/tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts b/tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts index e254e7a79fb..091cba5e3e4 100644 --- a/tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts +++ b/tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts @@ -106,8 +106,8 @@ describe("MiMo echo variants (#5724)", () => { } }); - test("a closed block whose body carries a literal is still matched whole", async () => { - for (const input of ["text('');", "text('');"]) { + test("a closed block whose body carries literal tool-call tags is still matched whole", async () => { + for (const input of ["text('');", "text('');", 'text("");']) { const block = `${input}`; for (const events of [await streamed(block, input), await buffered(block, input)]) { expect(visible(events)).toBe(""); From 3517ee0780a242ad50b50d87f3f264419d4b659a Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 15:07:21 +0900 Subject: [PATCH 4/4] fix(openai-chat): keep a closed block whose body has a line-start header A line-start header only bounds an unclosed candidate. When no appears before it, it is body text, and a closed after it still ends the block. --- src/adapters/openai-chat/serialized-tool-call-content.ts | 8 +++++--- .../openai-chat-serialized-tool-call-content.test.ts | 7 ++++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/adapters/openai-chat/serialized-tool-call-content.ts b/src/adapters/openai-chat/serialized-tool-call-content.ts index 9b76a2450a3..3a46c3ebbac 100644 --- a/src/adapters/openai-chat/serialized-tool-call-content.ts +++ b/src/adapters/openai-chat/serialized-tool-call-content.ts @@ -52,7 +52,9 @@ function endsWithAt(text: string, from: number, to: number, suffix: string): boo * MiMo's echo may close a freeform body with a stray `` and may omit `` * (#5724), the grammar the Command Code reader accepts too. The first `` preceded by * `` closes the block, so a body can still carry a literal `` or header; - * with none before the next line-start block header, the first `` does. + * with none before the next line-start block header, the first `` does. That header only + * bounds an unclosed candidate: with no close at all before it, it is body text, and a closed + * `` after it still ends the block. */ function blockAt(text: string, offset: number): SerializedToolCall | undefined { BLOCK_HEADER.lastIndex = offset; @@ -63,7 +65,7 @@ function blockAt(text: string, offset: number): SerializedToolCall | undefined { const next = NEXT_BLOCK_HEADER.exec(text); const limit = next ? next.index + 1 : text.length; let unclosed: SerializedToolCall | undefined; - for (let close = text.indexOf(CLOSE_TAG, bodyStart); close >= 0 && close < limit; + for (let close = text.indexOf(CLOSE_TAG, bodyStart); close >= 0 && (close < limit || !unclosed); close = text.indexOf(CLOSE_TAG, close + CLOSE_TAG.length)) { let bodyEnd = trimmedEnd(text, bodyStart, close); const closed = endsWithAt(text, bodyStart, bodyEnd, FUNCTION_CLOSE); @@ -76,7 +78,7 @@ function blockAt(text: string, offset: number): SerializedToolCall | undefined { end: close + CLOSE_TAG.length, }; if (closed) return call; - unclosed ??= call; + if (close < limit) unclosed ??= call; } return unclosed; } diff --git a/tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts b/tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts index 091cba5e3e4..7faf3a8572b 100644 --- a/tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts +++ b/tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts @@ -107,7 +107,12 @@ describe("MiMo echo variants (#5724)", () => { }); test("a closed block whose body carries literal tool-call tags is still matched whole", async () => { - for (const input of ["text('');", "text('');", 'text("");']) { + for (const input of [ + "text('');", + "text('');", + 'text("");', + 'const s = `\n`;\ntext(s);', + ]) { const block = `${input}`; for (const events of [await streamed(block, input), await buffered(block, input)]) { expect(visible(events)).toBe("");