mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-18 04:42:30 +03:00
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:
committed by
GitHub
parent
f653a6f94e
commit
c4293e28e2
@@ -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))
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 ?? "";
|
||||
|
||||
577
open-sse/utils/kimiToolCallNarration.ts
Normal file
577
open-sse/utils/kimiToolCallNarration.ts
Normal 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 },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
354
tests/unit/kimi-tool-call-narration.test.ts
Normal file
354
tests/unit/kimi-tool-call-narration.test.ts
Normal 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");
|
||||
});
|
||||
Reference in New Issue
Block a user