fix(cursor): recover Kimi tool calls emitted as history narration (#12723)

Merged. Kimi emitting tool calls as history narration is a provider quirk we have to absorb rather than pass through; recovering them keeps the tool contract intact for clients that never see the quirk.

Validated as a combined board first (this PR merged with the 21 siblings of the same wave on the release tip): eslint with the frozen suppressions, typecheck:core, check:open-sse-typecheck, complexity, cognitive-complexity, changelog-integrity, i18n new-key coverage, docs-counts, docs-sync, migration-numbering, provider-consistency and a duplicate-identifier audit all green, plus 176 passing / 0 failing focused node:test cases across the 25 test files the wave touches and the dashboard test under Vitest (2/0). Then re-validated alone on the fresh tip before this merge: conflicts re-resolved, file sizes rebaselined for this PR's own growth, eslint and this PR's focused tests re-run.

Thank you.
This commit is contained in:
Patryk Kopyciński
2026-09-16 20:34:54 +02:00
committed by GitHub
parent f653a6f94e
commit c4293e28e2
7 changed files with 993 additions and 26 deletions

View File

@@ -0,0 +1 @@
- **fix(cursor):** Kimi-k3 / kimi-k3-high on the Cursor provider sometimes emit tool calls by imitating the executor's own history narration ("Assistant called tool … with arguments: …") instead of using structured tool calls, so clients received raw narration text plus native generation delimiters with `finish_reason: "stop"` — and the leaked turn compounded on every subsequent request via history re-send; the cursor executor now detects this shape and reassembles it into a structured `tool_calls` entry in both streaming and non-streaming finalization paths, gated on "no structured tool calls yet" so healthy turns are untouched ([#12723](https://github.com/diegosouzapw/OmniRoute/pull/12723))

View File

@@ -94,11 +94,6 @@
"count": 3
}
},
"open-sse/executors/cursor.ts": {
"@typescript-eslint/no-unused-vars": {
"count": 2
}
},
"open-sse/executors/deepseek-web.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 12
@@ -731,11 +726,6 @@
"count": 1
}
},
"open-sse/utils/cursorAgentProtobuf.ts": {
"@typescript-eslint/no-unused-vars": {
"count": 2
}
},
"open-sse/utils/earlyStreamKeepalive.ts": {
"@typescript-eslint/no-unused-vars": {
"count": 1

View File

@@ -457,8 +457,8 @@
"open-sse/executors/antigravity.ts": 1665,
"open-sse/executors/base.ts": 1753,
"open-sse/executors/chatgpt-web.ts": 5056,
"open-sse/executors/codex.ts": 1505,
"open-sse/executors/cursor.ts": 1808,
"open-sse/executors/codex.ts": 1528,
"open-sse/executors/cursor.ts": 1847,
"open-sse/executors/muse-spark-web.ts": 1405,
"open-sse/handlers/chatCore.ts": 6156,
"open-sse/handlers/imageGeneration.ts": 3293,
@@ -695,5 +695,6 @@
"_rebaseline_2026_09_07_virtualfactory_crosses_the_new_file_cap": "open-sse/services/autoCombo/virtualFactory.ts crosses the 1200 new-file cap for the first time (1187 on the pre-wave tip, 1219 after the wave). Growth is spread across the routing/free-tier wave, not one extractable block: #12794 feeds observed breaker state and model quality into snapshot scoring instead of neutral constants, #12792 adds the reliability factor the snapshot path was still ignoring and the pooled-latency bootstrap, and #12744 tightens the free-model predicate the factory consumes, and #12795 records which filter stage emptied an auto/* pool. FROZEN RATHER THAN SPLIT, deliberately, and this is debt: two cohesive extraction candidates are ready when someone owns the move — computeSnapshotWeights (~85 lines) and the credential-eligibility group hasUsableOAuthToken/hasProviderSpecificSessionData/isKeylessEligibleConnection/hasUsableConnectionCredential (~70 lines). Either alone clears 1200 from here. Splitting three contributors' just-merged work mid-batch was the larger risk.",
"_rebaseline_2026_09_07_streaming_wave": "Stacked growth from the SSE/streaming wave. open-sse/handlers/chatCore.ts 6021->6026 (+5): #12854 seeds the in-memory pending continuation state synchronously, before saveCallLogOperation's first await, closing the window where resolvePreviousResponseState finds nothing because the artifact write has not landed yet. open-sse/utils/stream.ts 3080->3098 (+18): #12828 emits the trailing usage-estimate chunk on the translate flush (#12151 had only covered passthrough, so translate-mode clients never saw token counts) and #12718 stops rebuilding a truncated summary from the collector's cap-dropped event array. Irreducible at the existing chokepoints — both are the flush/finalization points themselves. Covered by the continuation-store, translate-usage and collector-truncation suites.",
"_rebaseline_2026_09_07_roundrobin_crosses_new_file_cap": "open-sse/services/combo/roundRobinCombo.ts 1198->1205, crossing the 1200 new-file cap. #12884 wires the quota-skip diagnostics into the round-robin attempt path so an ALL_TARGETS_SKIPPED 503 names which windows were exhausted instead of returning an opaque skip. The file was already at 1198 when #12811 lifted it out of combo.ts, so seven lines cross it; the diagnostics themselves live in quotaSkipDiagnostics.ts, under cap. Frozen rather than split: the natural next extraction is the attempt-loop body, which #12746/#12811 just moved and should settle before being cut again.",
"_rebaseline_2026_09_08_13033_responses_websearch_sse": "Own growth after rebase onto v3.8.51 tip af49d4972: open-sse/handlers/chatCore.ts 6036->6035 (-1, check-file-size split-newline). Branch stamps clientRequestedResponsesStream before web_search fallback forces stream:false, then wraps JSON via synthesizeOpenAiSseFromJson. Call-site wiring next to the existing web_search non-stream fallback; no new god-file. Covered tests/unit/responses-websearch-sse-13033.test.ts."
"_rebaseline_2026_09_08_13033_responses_websearch_sse": "Own growth after rebase onto v3.8.51 tip af49d4972: open-sse/handlers/chatCore.ts 6036->6035 (-1, check-file-size split-newline). Branch stamps clientRequestedResponsesStream before web_search fallback forces stream:false, then wraps JSON via synthesizeOpenAiSseFromJson. Call-site wiring next to the existing web_search non-stream fallback; no new god-file. Covered tests/unit/responses-websearch-sse-13033.test.ts.",
"_rebaseline_pr12723_kimi_narration": "PR #12723 Kimi narration recovery integration: cursor.ts 1764->1798 (+34 = +52 feature integration net, absorbed -18 of base growth headroom since the branch's own rebaseline). Every added line is functional PR wiring (scrubber creation with onToolCall emit closure, StreamCtx.narrationScrubber field, per-delta feed + finalizeKimiTurn hooks); the narration logic itself lives in open-sse/utils/kimiToolCallNarration.ts (593 lines). Covered by tests/unit/kimi-tool-call-narration.test.ts."
}

View File

@@ -91,6 +91,11 @@ import {
type ClassifiedCursorError,
} from "./cursor/cursorErrors.ts";
import { getActiveSyncedCatalog } from "../../src/lib/db/models/activeSyncedCatalog.ts";
import {
createNarrationStreamScrubber,
finalizeKimiTurn,
type NarrationStreamScrubber,
} from "../utils/kimiToolCallNarration.ts";
// Composer helpers re-exported for external importers (tests).
export {
isComposerModel,
@@ -253,12 +258,6 @@ const KV_GRACE_MS = (() => {
// turns the failure into a clean stream error instead of memory exhaustion.
const CURSOR_MAX_FRAME_BYTES = 16 * 1024 * 1024;
type CursorHttpResponse = {
status: number;
headers: Record<string, unknown>;
body: Buffer;
};
function tryParseJsonError(payload: Buffer): { message: string; status: number } | null {
if (payload.length < 2 || payload[0] !== 0x7b) return null;
try {
@@ -348,10 +347,16 @@ export type StreamCtx = {
// True once we've emitted structured tool_calls from the inline Composer parser
// (to avoid double-emitting if the block appears in multiple accumulated frames).
composerInlineToolCallsEmitted: boolean;
// History-dialect narration scrubber (PR #12723 follow-up): incrementally
// holds back text that could start a flattenMessages dialect construct
// ("Assistant called tool …", "Tool result (…):", "User: <tool_result>…")
// so mimicry of the gateway's own serialization never streams to the client.
// A finalize-time scrub alone cannot retract already-emitted deltas.
narrationScrubber: NarrationStreamScrubber;
};
export function newStreamCtx(model: string, emit: (chunk: string) => void): StreamCtx {
return {
const ctx: StreamCtx = {
responseId: `chatcmpl-cursor-${Date.now()}`,
created: Math.floor(Date.now() / 1000),
model,
@@ -371,7 +376,28 @@ export function newStreamCtx(model: string, emit: (chunk: string) => void): Stre
composerVisibleEmittedLength: 0,
composerToolParserState: isComposerModel(model) ? createStreamingState() : null,
composerInlineToolCallsEmitted: false,
// Assigned below (the scrubber's onToolCall callback closes over `ctx`).
narrationScrubber: undefined as unknown as NarrationStreamScrubber,
};
ctx.narrationScrubber = createNarrationStreamScrubber((tc) => {
// A narrated call surfaced by the scrubber mid-stream is emitted as a
// structured tool_calls chunk right away; the finalize path
// (applyKimiToolCallRecovery) will not re-add it because ctx.toolCalls
// is non-empty by then.
const index = ctx.emittedToolCallIndex++;
ctx.toolCalls.push({ id: tc.id, name: tc.function.name, argumentsJson: tc.function.arguments });
emitChunk(ctx, {
tool_calls: [
{
index,
id: tc.id,
type: "function",
function: { name: tc.function.name, arguments: tc.function.arguments },
},
],
});
});
return ctx;
}
function emitChunk(ctx: StreamCtx, delta: object, finishReason: string | null = null) {
@@ -660,9 +686,18 @@ export function processFrame(
emitChunk(ctx, { role: "assistant", content: "" });
ctx.emittedRoleChunk = true;
}
ctx.totalText += d.text;
// History-dialect scrub (PR #12723 follow-up): hold back text that may
// start a flattenMessages dialect construct so mimicry of the gateway's
// own serialization ("Assistant called tool …", "Tool result (…):",
// "User: <tool_result>…") never streams to the client. Only the
// scrubber-cleared delta is emitted and accumulated into totalText —
// totalText must equal what the client actually received.
const safeDelta = ctx.narrationScrubber.feed(d.text);
ctx.receivedText = true;
emitChunk(ctx, { content: d.text });
if (safeDelta) {
ctx.totalText += safeDelta;
emitChunk(ctx, { content: safeDelta });
}
} else if (d.kind === "thinking" && d.text) {
if (!ctx.emittedRoleChunk) {
emitChunk(ctx, { role: "assistant", content: "" });
@@ -1298,7 +1333,7 @@ export class CursorExecutor extends BaseExecutor {
});
}
async execute({ model, body, stream, credentials, signal, log, upstreamExtraHeaders }) {
async execute({ model, body, stream, credentials, signal, upstreamExtraHeaders }) {
const fallbackUrl = this.buildUrl();
const executionCredentials = await this.resolveExecutionCredentials(credentials);
if (executionCredentials instanceof Response) {
@@ -1662,6 +1697,8 @@ export class CursorExecutor extends BaseExecutor {
}
}
finalizeKimiTurn(ctx, (chunk) => emitChunk(ctx, chunk));
// OpenAI finish_reason: "tool_calls" if the model invoked any declared
// tool, else "stop". A turn with mixed text + tool_calls finishes with
// "tool_calls" (the tool calls are the actionable signal for the client).
@@ -1732,6 +1769,8 @@ export class CursorExecutor extends BaseExecutor {
}
}
finalizeKimiTurn(ctx);
const usage = buildCursorUsage(ctx, body);
const finishReason = ctx.toolCalls.length > 0 ? "tool_calls" : "stop";
const message: {

View File

@@ -19,6 +19,7 @@
import zlib from "node:zlib";
import crypto from "node:crypto";
import { decodeNativeTodoWriteCompletion } from "./cursorAgentProtobuf/nativeTodoWrite.ts";
import { scrubKimiNarrationText } from "./kimiToolCallNarration.ts";
import {
cursorImageAttachmentPath,
encodeSelectedImageBody,
@@ -62,7 +63,6 @@ const ARR_UNKNOWN_12 = 12; // observed varint=0 in cursor-agent traffic
const ARR_REQUEST_ID = 16; // observed UUID, same value as conversation_id
const CSS_ROOT_PROMPT = 1; // ConversationStateStructure.root_prompt_messages_json
const CSS_TURNS = 8; // ConversationStateStructure.turns
const CA_USER_MESSAGE_ACTION = 1; // ConversationAction.user_message_action
@@ -215,7 +215,6 @@ const MTD_TOOL_NAME = 5;
const MCA_NAME = 1;
const MCA_ARGS = 2; // map<string, bytes>
const MCA_TOOL_CALL_ID = 3;
const MCA_PROVIDER_IDENTIFIER = 4;
const MCA_TOOL_NAME = 5;
// McpResult variants
@@ -1469,7 +1468,13 @@ function messageContentToText(content: ChatMessage["content"]): string {
}
function assistantMessageLines(message: ChatMessage, text: string): string[] {
const lines = text ? [`Assistant: ${text}`] : [];
// History hygiene (PR #12723 follow-up): scrub gateway-dialect constructs
// from prior assistant visible text. A previously leaked narration /
// "User: <tool_result>" block re-sent in history re-anchors model mimicry
// of the gateway's own serialization on every subsequent turn — this
// breaks the compounding loop at the carrier. Only ASSISTANT text is
// scrubbed: a user legitimately quoting a dialect line must reach the model.
const lines = text ? [`Assistant: ${scrubKimiNarrationText(text).content || text}`] : [];
for (const toolCall of message.tool_calls ?? []) {
const name = toolCall.function?.name ?? "(unknown)";
const args = toolCall.function?.arguments ?? "";

View File

@@ -0,0 +1,577 @@
/**
* kimiToolCallNarration.ts — recover structured tool calls from Kimi models
* that mimic the executor's own history-narration format instead of emitting
* a native Cursor tool call.
*
* Root cause
* ----------
* Cursor's agent API accepts ONE user message per Run, so `flattenMessages`
* (cursorAgentProtobuf.ts) serializes prior `assistant.tool_calls` into plain
* text for the model to read as context:
*
* Assistant called tool <name> (<id>) with arguments: <json>
*
* Kimi-k3 / kimi-k3-high imitate that narration when they decide to call a
* tool. Instead of a structured tool call the model emits the narration as
* visible text, appends the complete JSON arguments, then closes with its
* native generation-grammar delimiters:
*
* <|close|>argument<|sep|><|close|>call<|sep|><|close|>tools<|sep|>
*
* Cursor's protobuf backend passes the whole thing through verbatim as text
* with `finish_reason: "stop"`. The client then renders the raw narration
* plus delimiters instead of a tool card, and — because the bad turn is
* re-sent in history — the leak compounds on every subsequent turn.
*
* Follow-up (2026-09-15, PR #12723): two gaps found in production.
*
* 1. The recovery only ran when NO structured tool call was emitted. Kimi
* frequently emits a real structured call AND narrates it in visible
* text in the same turn; the narration then leaked verbatim (and was
* re-sent in history, seeding further mimicry — including on other
* cursor models such as composer-2.5-fast that regurgitate flattened
* history).
* 2. The models also mimic the *result* serialization — both
* flattenMessages' `Tool result (<id>): …` line and the translator's
* `<tool_result><tool_name>…</tool_call_id><result>…</tool_result>`
* XML block — hallucinating a `User:` message that the client renders
* as visible conversation content.
*
* This module therefore provides three layers:
*
* - recoverKimiToolCallNarration(): synthesize a structured tool call from
* narration text (unchanged behavior, existing callers/tests).
* - createNarrationStreamScrubber(): an incremental holdback state machine
* used on the live text-delta path so dialect text NEVER reaches the
* client SSE stream in the first place (a finalize-time scrub cannot
* retract already-streamed deltas). Captures valid narrated calls via
* callback so the executor can still emit them as structured calls.
* - scrubKimiNarrationText(): batch scrub (feed+finish over the state
* machine) used as a safety net at finalization and to clean poisoned
* assistant history in flattenMessages.
*
* All gating is by CONTENT SHAPE, never by model name: the dialects are
* machine-generated gateway formats that never legitimately appear inside
* assistant visible output.
*/
export interface RecoveredToolCall {
id: string;
type: "function";
function: { name: string; arguments: string };
}
export interface NarrationRecoveryResult {
/** Visible content with the narration line and delimiter tail removed. */
content: string;
toolCalls: RecoveredToolCall[];
}
// The narration line that flattenMessages emits for prior assistant tool
// calls. Kimi reproduces it verbatim when it wants to call a tool. The id is
// wrapped in parens and may itself contain parens (the "(unknown)" placeholder
// is emitted as "((unknown))"), so the id group tolerates one nested level.
const NARRATION_RE =
/Assistant called tool ([\w.\-:]+) \(((?:[^()]|\([^()]*\))*)\) with arguments: /;
// A single fragment of the Kimi closing grammar: a delimiter token
// (<|close|> / <|sep|>) or one of the grammar keywords that sit between
// delimiter tokens (argument / call / tools / name).
const TAIL_FRAGMENT = "<\\|(?:close|sep)\\|>|argument|call|tools|name";
// Matches the whole closing-grammar chain wherever it appears (used when the
// delimiters sit mid-string, before residual trailing prose).
const DELIM_CHAIN_RE = new RegExp("(?:\\s*(?:" + TAIL_FRAGMENT + "))+", "gu");
// Balanced-JSON scan: starting at `start`, return the end index (exclusive) of
// the first complete {...} object, honoring strings and escapes. -1 if the
// object never closes (truncated).
type JsonScanState = { depth: number; inStr: boolean; esc: boolean };
// One character of the balanced-JSON walk. Returns true when the outermost
// object just closed (caller records the end index).
function stepJsonScan(state: JsonScanState, c: string | undefined): boolean {
if (state.esc) {
state.esc = false;
return false;
}
if (c === "\\") {
state.esc = true;
return false;
}
if (c === '"') {
state.inStr = !state.inStr;
return false;
}
if (state.inStr) return false;
if (c === "{") state.depth++;
else if (c === "}") return --state.depth === 0;
return false;
}
function scanJsonObjectEnd(text: string, start: number): number {
if (text[start] !== "{") return -1;
const state: JsonScanState = { depth: 0, inStr: false, esc: false };
for (let i = start; i < text.length; i++) {
if (stepJsonScan(state, text[i])) return i + 1;
}
return -1;
}
/**
* Attempt to recover tool calls from Kimi narration text.
* Returns null when the text does not match the narration shape (caller leaves
* the response untouched).
*/
export function recoverKimiToolCallNarration(text: string): NarrationRecoveryResult | null {
if (!text) return null;
const m = NARRATION_RE.exec(text);
if (!m) return null;
const jsonStart = m.index + m[0].length;
const jsonEnd = scanJsonObjectEnd(text, jsonStart);
if (jsonEnd < 0) return null; // truncated arguments — nothing reliable to emit
const argsJson = text.slice(jsonStart, jsonEnd);
try {
JSON.parse(argsJson);
} catch {
return null;
}
// Everything after the JSON: remove the delimiter-grammar chain wherever it
// appears (the model may append trailing prose after the delimiters).
const rawTail = text.slice(jsonEnd);
const tail = rawTail.replace(DELIM_CHAIN_RE, " ").replace(/\s+/gu, " ").trim();
// Visible content = prose before the narration line, plus any residual
// non-delimiter tail text.
const before = text.slice(0, m.index).replace(/[\s\n]+$/u, "");
const content = tail ? (before ? before + "\n" + tail : tail) : before;
const name = m[1];
const rawId = m[2];
const id = rawId && rawId !== "(unknown)" && rawId !== "unknown" ? rawId : genId();
return {
content,
toolCalls: [{ id, type: "function", function: { name, arguments: argsJson } }],
};
}
function genId(): string {
return "call_" + Math.random().toString(36).slice(2, 14);
}
// ─── Streaming narration scrubber ───────────────────────────────────────────
//
// Incremental holdback state machine. Text that could be the start of a
// history-dialect trigger is buffered instead of emitted; once a trigger
// completes, the whole dialect construct (narration line + JSON arguments +
// delimiter tail, a `Tool result (…):` line, or a `<tool_result>…</tool_result>`
// block) is dropped. Valid narrated JSON arguments are surfaced through the
// onToolCall callback so the executor can still emit a structured call.
//
// States:
// scan — copying text through, holding back partial-trigger suffixes
// narrHead — saw "Assistant called tool", waiting for " with arguments: "
// on the same line (false alarm flushes the line as prose)
// narrJson — inside the narrated JSON arguments (balanced-brace scan)
// delims — consuming the <|close|>/<|sep|> closing-grammar chain
// xml — inside a <tool_result> … </tool_result> block
// lineDrop — consuming a "Tool result (…): …" line to its newline (dropped)
// userWait — saw "User:", holding to see whether "<tool_result>" follows
const TRIGGERS = ["Assistant called tool", "Tool result (", "<tool_result>", "User:"] as const;
const MAX_TRIGGER_LEN = 20; // length of "Assistant called tool"
const NARRATION_MARKER = " with arguments: ";
const XML_OPEN = "<tool_result>";
const XML_CLOSE = "</tool_result>";
const DELIM_TOKEN_RE = /^\s*<\|(?:close|sep)\|>/;
// Proper prefix of a delimiter token: "", "<", "<|", "<|c", … "<|close", "<|s" … "<|sep".
const DELIM_PARTIAL_RE = /^<\|?(?:(?:c|cl|clo|clos|close|s|se|sep)\|?)?$/;
// A grammar keyword is only part of the chain when another delimiter follows it.
const DELIM_KEYWORD_RE = /^(?:argument|call|tools|name)(?=\s*<\|)/;
// Complete keyword + complete delimiter token at buf head (char-by-char mode:
// the pair arrives incrementally, so token-anchored matching alone never
// consumes it and the machine stalls waiting for a lookahead that already
// arrived).
const DELIM_KEYWORD_TOKEN_RE = /^(?:argument|call|tools|name)\s*<\|(?:close|sep)\|>/;
// Proper prefix of a grammar keyword (char-by-char streaming holdback), or a
// complete keyword still waiting for its lookahead delimiter, or a keyword
// followed by a partial delimiter token ("argument<", "call<|s").
const DELIM_KEYWORD_PARTIAL_RE =
/^(?:(?:a|ar|arg|argu|argum|argume|argumen|argument|c|ca|cal|call|t|to|too|tool|tools|n|na|nam|name)\s*)?(?:<\|?(?:(?:c|cl|clo|clos|close|s|se|sep)\|?)?)?$/;
export type NarrationStreamScrubber = {
/** Feed one raw text delta; returns the safe-to-emit portion (may be ""). */
feed(delta: string): string;
/** End of stream: returns residual clean text (truncated dialect is dropped). */
finish(): string;
};
type ScrubState = "scan" | "narrHead" | "narrJson" | "delims" | "xml" | "lineDrop" | "userWait";
type ScrubCtx = {
state: ScrubState;
buf: string;
out: string;
// narrJson bookkeeping
jsonScanned: number;
json: JsonScanState;
narrName: string;
narrId: string;
onToolCall?: (tc: RecoveredToolCall) => void;
};
function flush(c: ScrubCtx, text: string): void {
if (text) c.out += text;
}
// Largest prefix length of b that cannot still extend into a trigger
// (i.e. no suffix starting inside the prefix is a proper prefix of a trigger).
function safeLen(b: string): number {
for (let i = Math.max(0, b.length - MAX_TRIGGER_LEN); i < b.length; i++) {
const sfx = b.slice(i);
for (const t of TRIGGERS) {
if (t.startsWith(sfx) && sfx.length < t.length) return i;
}
}
return b.length;
}
function scanStep(c: ScrubCtx): void {
let best = -1;
let bestTrig = "";
for (const t of TRIGGERS) {
const i = c.buf.indexOf(t);
if (i !== -1 && (best === -1 || i < best)) {
best = i;
bestTrig = t;
}
}
if (best === -1) {
const n = safeLen(c.buf);
flush(c, c.buf.slice(0, n));
c.buf = c.buf.slice(n);
return; // stay in scan, buf holds the partial suffix
}
flush(c, c.buf.slice(0, best));
c.buf = c.buf.slice(best);
enterState(c, bestTrig);
}
function enterState(c: ScrubCtx, trig: string): void {
// Every branch must invoke its step function: enterState is also reached
// recursively (delims → scan → enterState on a batch feed), and without
// the step call the machine settles mid-construct and drops the rest.
if (trig === "Assistant called tool") {
c.state = "narrHead";
narrHeadStep(c);
} else if (trig === "Tool result (") {
c.state = "lineDrop";
lineDropStep(c);
} else if (trig === "<tool_result>") {
c.buf = c.buf.slice(trig.length);
c.state = "xml";
xmlStep(c);
} else {
// "User:" — wait for a possible "<tool_result>" continuation.
c.state = "userWait";
userStep(c);
}
}
function xmlStep(c: ScrubCtx): void {
const end = c.buf.indexOf(XML_CLOSE);
if (end !== -1) {
let rest = c.buf.slice(end + XML_CLOSE.length);
if (rest.startsWith("\n")) rest = rest.slice(1);
c.buf = rest;
c.state = "scan";
scanStep(c);
}
// else: keep buffering until the closing tag (or EOF → finish drops it).
}
function userCompatible(c: ScrubCtx, post: string): boolean {
// True while post can still extend into `\s*<tool_result>`, or already
// starts with the full opener (then the block is confirmed: drop the
// "User:" prefix along with it and enter the xml state).
const ws = post.length - post.trimStart().length;
const rest = post.slice(ws);
if (rest.startsWith(XML_OPEN)) {
// Confirmed "User: <tool_result>" — drop both, consume into xml state.
c.buf = rest.slice(XML_OPEN.length);
c.state = "xml";
xmlStep(c);
return true;
}
return rest === "" || XML_OPEN.startsWith(rest);
}
function userStep(c: ScrubCtx): void {
// buf starts with "User:".
const post = c.buf.slice("User:".length);
if (userCompatible(c, post)) return; // still ambiguous, keep holding
// Not the XML block: "User:" is plain prose here. Emit it verbatim and
// rescan the remainder (it may itself contain later triggers).
flush(c, "User:");
c.buf = post;
c.state = "scan";
scanStep(c);
}
function narrHeadStep(c: ScrubCtx): void {
const nl = c.buf.indexOf("\n");
const marker = c.buf.indexOf(NARRATION_MARKER);
if (marker !== -1 && (nl === -1 || marker < nl)) {
const head = c.buf.slice(0, marker + NARRATION_MARKER.length);
const m = NARRATION_RE.exec(head);
c.narrName = m ? m[1] : "";
c.narrId = m && m[2] && m[2] !== "(unknown)" && m[2] !== "unknown" ? m[2] : "";
c.buf = c.buf.slice(marker + NARRATION_MARKER.length);
c.jsonScanned = 0;
c.json = { depth: 0, inStr: false, esc: false };
c.state = "narrJson";
narrJsonStep(c);
return;
}
if (nl !== -1) {
// Line ended without the marker → prose that happens to start with the
// trigger. Emit the whole line (no trigger contains "\n", so the line
// cannot end mid-trigger) and rescan the remainder.
flush(c, c.buf.slice(0, nl + 1));
c.buf = c.buf.slice(nl + 1);
c.state = "scan";
scanStep(c);
}
// else: still on the head line, keep holding.
}
function emitNarratedToolCall(c: ScrubCtx, argsJson: string): void {
if (!c.narrName || !c.onToolCall) return;
try {
JSON.parse(argsJson);
} catch {
return; // malformed arguments — drop rather than emit garbage
}
c.onToolCall({
id: c.narrId || genId(),
type: "function",
function: { name: c.narrName, arguments: argsJson },
});
}
function narrJsonStep(c: ScrubCtx): void {
for (let i = c.jsonScanned; i < c.buf.length; i++) {
c.jsonScanned = i + 1;
if (stepJsonScan(c.json, c.buf[i])) {
emitNarratedToolCall(c, c.buf.slice(0, i + 1));
c.buf = c.buf.slice(i + 1);
c.state = "delims";
delimsStep(c);
return;
}
}
// JSON not closed yet — keep buffering.
}
function delimPartial(buf: string): boolean {
// After a complete chain element, the remaining head may be whitespace or
// a partial next token ("<", "<|c", "<|s") or a partial/complete grammar
// keyword still waiting on its lookahead delimiter.
const trimmed = buf.trimStart();
if (trimmed === "") return true;
if (DELIM_PARTIAL_RE.test(trimmed)) return true;
if (DELIM_KEYWORD_RE.test(trimmed)) return true;
if (DELIM_KEYWORD_PARTIAL_RE.test(trimmed)) return true;
return false;
}
function delimsStep(c: ScrubCtx): void {
for (;;) {
const m = DELIM_TOKEN_RE.exec(c.buf);
if (m) {
c.buf = c.buf.slice(m[0].length);
const kw = DELIM_KEYWORD_RE.exec(c.buf);
if (kw) c.buf = c.buf.slice(kw[0].length);
continue;
}
// Keyword-first split (incremental arrival): consume the pair whole.
const kwTok = DELIM_KEYWORD_TOKEN_RE.exec(c.buf);
if (kwTok) {
c.buf = c.buf.slice(kwTok[0].length);
continue;
}
if (delimPartial(c.buf)) return; // wait for more text
// Chain over — the remainder is ordinary text.
c.buf = c.buf.replace(/^\s+/u, "");
c.state = "scan";
scanStep(c);
return;
}
}
function lineDropStep(c: ScrubCtx): void {
const nl = c.buf.indexOf("\n");
if (nl !== -1) {
c.buf = c.buf.slice(nl + 1);
c.state = "scan";
scanStep(c);
}
// else: keep buffering the dropped line.
}
const STEP_BY_STATE: Record<ScrubState, (c: ScrubCtx) => void> = {
scan: scanStep,
narrHead: narrHeadStep,
narrJson: narrJsonStep,
delims: delimsStep,
xml: xmlStep,
lineDrop: lineDropStep,
userWait: userStep,
};
/** Drive the machine until it settles in a buffering state. */
function driveScrub(c: ScrubCtx): void {
for (let guard = 0; guard < 50; guard++) {
const before = c.state;
STEP_BY_STATE[before](c);
if (c.state === before) return; // settled: waiting for more input
}
}
/** EOF residue: only `scan`/`userWait`/`narrHead` hold real prose. */
function finishResidue(c: ScrubCtx): void {
if (c.state === "scan" || c.state === "narrHead") {
flush(c, c.buf); // partial trigger/head at EOF can never complete → prose
return;
}
if (c.state === "userWait") {
const rest = c.buf.slice("User:".length).trimStart();
// "User:" followed by a truncated XML opener: keep "User:" as prose
// (it was real text), drop the partial tag.
flush(c, XML_OPEN.startsWith(rest) && rest !== "" ? "User:" : c.buf);
}
// narrJson/delims/xml/lineDrop: truncated dialect — drop.
}
export function createNarrationStreamScrubber(
onToolCall?: (tc: RecoveredToolCall) => void
): NarrationStreamScrubber {
const c: ScrubCtx = {
state: "scan",
buf: "",
out: "",
jsonScanned: 0,
json: { depth: 0, inStr: false, esc: false },
narrName: "",
narrId: "",
onToolCall,
};
return {
feed(delta: string): string {
if (!delta) return "";
c.buf += delta;
c.out = "";
driveScrub(c);
return c.out;
},
finish(): string {
c.out = "";
finishResidue(c);
c.buf = "";
c.state = "scan";
return c.out;
},
};
}
/**
* Batch scrub: remove every history-dialect construct from `text`.
* Returns the cleaned content and whether anything changed.
*/
export function scrubKimiNarrationText(text: string): { content: string; changed: boolean } {
if (!text) return { content: text, changed: false };
const s = createNarrationStreamScrubber();
const content = s.feed(text) + s.finish();
return { content, changed: content !== text };
}
// ─── Executor integration ────────────────────────────────────────────────────
export interface KimiRecoveryCtx {
totalText: string;
toolCalls: Array<{ id: string; name: string; argumentsJson: string }>;
emittedToolCallIndex?: number;
}
/**
* One-line-per-site executor hook: flush the narration scrubber's held-back
* prose, then run finalize-time recovery + scrub (applyKimiToolCallRecovery).
* Kept here so open-sse/executors/cursor.ts — a file-size-frozen file — does
* not grow per integration site (base growth consumed the old headroom).
*/
export function finalizeKimiTurn(
ctx: KimiRecoveryCtx & { narrationScrubber?: { finish(): string | null } },
emit?: (chunk: { content?: string; tool_calls?: unknown[] }) => void
): boolean {
const flush = ctx.narrationScrubber?.finish() ?? null;
if (flush) {
ctx.totalText += flush;
emit?.({ content: flush });
}
return applyKimiToolCallRecovery(ctx, (c) => emit?.(c));
}
/**
* Finalize-time recovery + scrub.
*
* 1. When no structured tool call was produced, try to synthesize one from a
* narrated call (original PR #12723 behavior).
* 2. ALWAYS scrub any residual dialect text (narration echo alongside a real
* structured call, `Tool result (…):` lines, `User: <tool_result>` blocks,
* orphan delimiter chains). Previously the whole function was skipped when
* a structured call existed — that gating hole is what let the leak
* compound in history.
*/
export function applyKimiToolCallRecovery(
ctx: KimiRecoveryCtx,
emit?: (chunk: { tool_calls: unknown[] }) => void
): boolean {
if (!ctx.totalText) return false;
let recovered = false;
if (ctx.toolCalls.length === 0) {
const r = recoverKimiToolCallNarration(ctx.totalText);
if (r && r.toolCalls.length > 0) {
ctx.totalText = r.content;
for (const tc of r.toolCalls) recordRecoveredCall(ctx, tc, emit);
recovered = true;
}
}
const scrubbed = scrubKimiNarrationText(ctx.totalText);
if (scrubbed.changed) ctx.totalText = scrubbed.content;
return recovered;
}
/** Push one recovered call into ctx (and emit it when streaming). */
function recordRecoveredCall(
ctx: KimiRecoveryCtx,
tc: RecoveredToolCall,
emit?: (chunk: { tool_calls: unknown[] }) => void
): void {
const index = ctx.emittedToolCallIndex ?? 0;
if (ctx.emittedToolCallIndex !== undefined) ctx.emittedToolCallIndex++;
ctx.toolCalls.push({ id: tc.id, name: tc.function.name, argumentsJson: tc.function.arguments });
emit?.({
tool_calls: [
{
index,
id: tc.id,
type: "function",
function: { name: tc.function.name, arguments: tc.function.arguments },
},
],
});
}

View File

@@ -0,0 +1,354 @@
/**
* Tests for kimiToolCallNarration.ts — recovers structured tool calls from
* Kimi models that mimic flattenMessages' "Assistant called tool ..." history
* narration and append native closing delimiters, instead of emitting a
* native Cursor tool call.
*
* Follow-up (2026-09-15): also covers the streaming narration scrubber and
* the tool-result dialects ("Tool result (…): …", "User: <tool_result>…")
* observed leaking in production.
*/
import test from "node:test";
import assert from "node:assert/strict";
import {
recoverKimiToolCallNarration,
createNarrationStreamScrubber,
scrubKimiNarrationText,
applyKimiToolCallRecovery,
} from "../../open-sse/utils/kimiToolCallNarration.ts";
const DELIM =
"<|close|>" +
"argument" +
"<|sep|>" +
"<|close|>" +
"call" +
"<|sep|>" +
"<|close|>" +
"tools" +
"<|sep|>";
test("returns null for plain prose", () => {
assert.equal(recoverKimiToolCallNarration("Hello world"), null);
});
test("returns null for empty string", () => {
assert.equal(recoverKimiToolCallNarration(""), null);
});
test("recovers a tool call and strips the delimiter tail", () => {
const text =
"No new commit yet after ~8 minutes. Polling again.\n\n" +
"Assistant called tool buzz-dev-mcp__shell (toolu_01PollBanner2) with arguments: " +
'{"command":"sleep 480","timeout_ms":540000}' +
DELIM;
const r = recoverKimiToolCallNarration(text);
assert.ok(r, "should recover");
assert.equal(r.toolCalls.length, 1);
const tc = r.toolCalls[0];
assert.equal(tc.type, "function");
assert.equal(tc.function.name, "buzz-dev-mcp__shell");
assert.equal(tc.id, "toolu_01PollBanner2");
assert.deepEqual(JSON.parse(tc.function.arguments), {
command: "sleep 480",
timeout_ms: 540000,
});
// Visible content keeps the prose, drops narration + delimiters.
assert.equal(r.content, "No new commit yet after ~8 minutes. Polling again.");
assert.ok(!r.content.includes("Assistant called tool"));
assert.ok(!r.content.includes("<|close|>"));
});
test("handles args containing braces, escaped quotes and newlines", () => {
const args = JSON.stringify({
command: 'python3 -c "import sys; print(\\"a}\\")" && echo {x}',
nested: { a: [1, 2, { b: "}" }] },
});
const text =
"Working on it.\n\nAssistant called tool terminal (call_abc123) with arguments: " +
args +
DELIM;
const r = recoverKimiToolCallNarration(text);
assert.ok(r);
assert.equal(r.toolCalls[0].function.name, "terminal");
assert.equal(r.toolCalls[0].id, "call_abc123");
assert.deepEqual(JSON.parse(r.toolCalls[0].function.arguments), JSON.parse(args));
assert.equal(r.content, "Working on it.");
});
test("returns null when arguments JSON is truncated (unbalanced)", () => {
const text =
'Assistant called tool terminal (call_x) with arguments: {"command":"unterminated' + DELIM;
assert.equal(recoverKimiToolCallNarration(text), null);
});
test("returns null when arguments are not valid JSON", () => {
const text = "Assistant called tool terminal (call_x) with arguments: {not json}" + DELIM;
assert.equal(recoverKimiToolCallNarration(text), null);
});
test("generates an id when narration placeholder is (unknown)", () => {
const text =
'Assistant called tool read_file ((unknown)) with arguments: {"path":"/tmp/a"}' + DELIM;
const r = recoverKimiToolCallNarration(text);
assert.ok(r);
assert.match(r.toolCalls[0].id, /^call_/);
});
test("preserves residual non-delimiter tail content after the tool call", () => {
const text =
'Assistant called tool terminal (call_y) with arguments: {"command":"ls"}' +
DELIM +
"\nSome trailing prose.";
const r = recoverKimiToolCallNarration(text);
assert.ok(r);
assert.equal(r.toolCalls.length, 1);
assert.ok(r.content.includes("Some trailing prose."));
assert.ok(!r.content.includes("<|close|>"));
});
test("does not fire when there is no narration marker (plain delimiter junk)", () => {
const text = "some output " + "<|close|>" + "argument" + "<|sep|>";
assert.equal(recoverKimiToolCallNarration(text), null);
});
// ─── Streaming scrubber ──────────────────────────────────────────────────────
test("streaming: passes plain prose through verbatim", () => {
const s = createNarrationStreamScrubber();
const out = s.feed("Hello world, this is a normal sentence.\n") + s.finish();
assert.equal(out, "Hello world, this is a normal sentence.\n");
});
test("streaming: holds back and drops a full narration + delimiter tail", () => {
const s = createNarrationStreamScrubber();
let out = s.feed("Prose before.\n\n");
out += s.feed("Assistant called tool terminal (call_1) with arguments: ");
out += s.feed('{"command":"ls -la"}');
out += s.feed(DELIM);
out += s.feed("\nProse after.");
out += s.finish();
assert.equal(out, "Prose before.\n\nProse after.");
});
test("streaming: surfaces narrated call via onToolCall callback", () => {
const calls: Array<{ id: string; name: string; args: string }> = [];
const s = createNarrationStreamScrubber((tc) =>
calls.push({ id: tc.id, name: tc.function.name, args: tc.function.arguments })
);
s.feed("Assistant called tool terminal (call_9) with arguments: ");
s.feed('{"command":"pwd"}');
s.feed(DELIM);
s.finish();
assert.equal(calls.length, 1);
assert.equal(calls[0].name, "terminal");
assert.equal(calls[0].id, "call_9");
assert.deepEqual(JSON.parse(calls[0].args), { command: "pwd" });
});
test("streaming: narration split across many tiny deltas is fully held back", () => {
const s = createNarrationStreamScrubber();
const full =
'Intro.\n\nAssistant called tool terminal (call_2) with arguments: {"code":"print(1)"}' +
DELIM +
"\nDone.";
let out = "";
for (const ch of full) out += s.feed(ch);
out += s.finish();
// Prose preserved in order; no dialect remains. (Exact blank-line count
// around a dropped construct is unspecified.)
assert.ok(out.startsWith("Intro."));
assert.ok(out.includes("Done."));
assert.ok(!out.includes("Assistant called tool"));
assert.ok(!out.includes("<|close|>"));
assert.ok(!out.includes("print(1)"));
});
test("streaming: char-by-char narration also fires onToolCall", () => {
const calls: unknown[] = [];
const s = createNarrationStreamScrubber(() => calls.push(1));
const full = 'Assistant called tool execute_code (call_3) with arguments: {"code":"x=1"}' + DELIM;
for (const ch of full) s.feed(ch);
s.finish();
assert.equal(calls.length, 1);
});
test("streaming: drops Tool result (…) line", () => {
const s = createNarrationStreamScrubber();
let out = s.feed("Before.\n");
out += s.feed("Tool result (call_verify_gw_npm): \n");
out += s.feed("After.");
out += s.finish();
assert.equal(out, "Before.\nAfter.");
});
test("streaming: drops User: <tool_result> XML block", () => {
const s = createNarrationStreamScrubber();
let out = s.feed("Before.\n\n");
out += s.feed("User: <tool_result>\n");
out += s.feed("<tool_name>execute_code</tool_name>\n");
out += s.feed("<tool_call_id>call_verify_gw_npm</tool_call_id>\n");
out += s.feed('<result>{"status": "success", "output": "ok"}</result>\n');
out += s.feed("</tool_result>\n");
out += s.feed("After.");
out += s.finish();
assert.equal(out, "Before.\n\nAfter.");
});
test("streaming: plain 'User:' prose is NOT dropped", () => {
const s = createNarrationStreamScrubber();
let out = s.feed("The model wrote User: hello there.\n");
out += s.finish();
assert.equal(out, "The model wrote User: hello there.\n");
});
test("streaming: 'User:' followed by unrelated text is kept verbatim", () => {
const s = createNarrationStreamScrubber();
let out = s.feed("User: what is going on?\n");
out += s.finish();
assert.equal(out, "User: what is going on?\n");
});
test("streaming: prose line that merely starts with 'Assistant called tool' is kept", () => {
const s = createNarrationStreamScrubber();
let out = s.feed("Assistant called tool is a phrase models imitate.\nNext line.\n");
out += s.finish();
assert.equal(out, "Assistant called tool is a phrase models imitate.\nNext line.\n");
});
test("streaming: partial trigger at EOF flushes as prose", () => {
const s = createNarrationStreamScrubber();
let out = s.feed("Trailing text Assistant called");
out += s.finish();
assert.equal(out, "Trailing text Assistant called");
});
test("streaming: truncated narration JSON at EOF is dropped", () => {
const s = createNarrationStreamScrubber();
let out = s.feed("Prose.\nAssistant called tool terminal (call_t) with arguments: ");
out += s.feed('{"command":"never');
out += s.finish();
assert.equal(out, "Prose.\n");
});
test("streaming: truncated <tool_result> block at EOF is dropped", () => {
const s = createNarrationStreamScrubber();
let out = s.feed("Prose.\nUser: <tool_result>\n<tool_name>x</tool_name>\n");
out += s.finish();
assert.equal(out, "Prose.\n");
});
test("streaming: recovered call from truncated-then-completed narration", () => {
// Narration arriving in awkward split: head marker split across feeds.
const s = createNarrationStreamScrubber();
let out = s.feed("A.\nAssistant called");
out += s.feed(" tool terminal (call_z) with arguments: ");
out += s.feed('{"a":');
out += s.feed("1}");
out += s.feed("<|close|>argument<|sep|>");
out += s.finish();
assert.equal(out, "A.\n");
});
test("batch scrub removes every dialect from the production leak specimen", () => {
// Shape taken from the 2026-09-15 production leak (message id 1488693):
// prose + narration + delimiters + narration again (no delimiters) +
// "Tool result (…):" line + "User: <tool_result>…" block.
const specimen =
"WAL checkpointed — 64MB → 0B. Verifying the gateway.\n\n" +
"Assistant called tool execute_code (call_verify_gw_npm) with arguments: " +
'{"code":"print(1)"}' +
DELIM +
"\n\n" +
"Assistant called tool execute_code (call_verify_gw_npm) with arguments: " +
'{"code":"print(2)"}' +
"\n\n" +
"Tool result (call_verify_gw_npm): \n\n" +
"User: <tool_result>\n" +
"<tool_name>execute_code</tool_name>\n" +
"<tool_call_id>call_verify_gw_npm</tool_call_id>\n" +
'<result>{"status": "success", "output": "$ health attempt 1"}</result>\n' +
"</tool_result>\n";
const r = scrubKimiNarrationText(specimen);
assert.ok(r.changed);
assert.ok(r.content.startsWith("WAL checkpointed — 64MB → 0B. Verifying the gateway."));
assert.ok(!r.content.includes("Assistant called tool"));
assert.ok(!r.content.includes("Tool result ("));
assert.ok(!r.content.includes("<tool_result>"));
assert.ok(!r.content.includes("<tool_name>"));
assert.ok(!r.content.includes("User:"));
assert.ok(!r.content.includes("<|close|>"));
assert.ok(!r.content.includes("print(1)"));
assert.ok(!r.content.includes("print(2)"));
assert.ok(!r.content.includes("health attempt"));
});
test("batch scrub leaves clean text unchanged", () => {
const r = scrubKimiNarrationText("Just a normal assistant reply.\nSecond line.");
assert.equal(r.content, "Just a normal assistant reply.\nSecond line.");
assert.equal(r.changed, false);
});
// ─── applyKimiToolCallRecovery: gating fix ───────────────────────────────────
test("recovery: scrub now runs even when structured tool calls exist", () => {
// The production gating hole: a real structured call AND narration text in
// the same turn. Previously returned early (toolCalls non-empty) and leaked.
const ctx = {
totalText:
"Done. WAL is clean.\n\n" +
"Assistant called tool execute_code (call_a) with arguments: " +
'{"code":"x"}' +
DELIM,
toolCalls: [{ id: "call_real", name: "terminal", argumentsJson: '{"command":"ls"}' }],
emittedToolCallIndex: 1,
};
const before = ctx.toolCalls.length;
const recovered = applyKimiToolCallRecovery(ctx);
assert.equal(recovered, false); // no NEW calls synthesized
assert.equal(ctx.toolCalls.length, before); // existing call untouched
assert.ok(ctx.totalText.startsWith("Done. WAL is clean."));
assert.ok(!ctx.totalText.includes("Assistant called tool"));
assert.ok(!ctx.totalText.includes("<|close|>"));
});
test("recovery: still synthesizes a call when none structured existed", () => {
const ctx = {
totalText:
'Working.\nAssistant called tool terminal (call_b) with arguments: {"command":"ls"}' + DELIM,
toolCalls: [],
emittedToolCallIndex: 0,
};
const emitted: unknown[] = [];
const ok = applyKimiToolCallRecovery(ctx, (c) => emitted.push(c));
assert.equal(ok, true);
assert.equal(ctx.toolCalls.length, 1);
assert.equal(ctx.toolCalls[0].id, "call_b");
assert.equal(emitted.length, 1);
assert.equal(ctx.totalText, "Working.");
});
test("recovery: empty text returns false, no crash", () => {
const ctx = { totalText: "", toolCalls: [], emittedToolCallIndex: 0 };
assert.equal(applyKimiToolCallRecovery(ctx), false);
});
// EOF while still on an unterminated "Assistant called tool" head line: the
// line never reached the narration marker, so it was ordinary prose and must
// be emitted verbatim rather than swallowed as truncated dialect.
test("streaming: unterminated narration head at EOF is flushed as prose", () => {
const s = createNarrationStreamScrubber();
let out = s.feed("Assistant called tool");
out += s.finish();
assert.equal(out, "Assistant called tool");
});
test("streaming: narration head text without marker survives EOF mid-line", () => {
const s = createNarrationStreamScrubber();
let out = s.feed("Assistant called tool xyz");
out += s.finish();
assert.equal(out, "Assistant called tool xyz");
});