From b4e17274ed657046838fcb569768612a5632be96 Mon Sep 17 00:00:00 2001 From: Bertan Ari Date: Fri, 7 Aug 2026 15:02:26 -0700 Subject: [PATCH 1/2] fix(vscode-lm): sanitize surrogates, recover leaked tool calls, and window-safe tool_result truncation Hardens the VS Code Language Model provider (notably GitHub Copilot serving Anthropic Claude) against three failure modes: - Surrogate sanitization: a lone UTF-16 surrogate cannot be encoded as UTF-8, so the backend rejects the entire request with a 400. sanitizeSurrogates() replaces unpaired surrogates with U+FFFD while preserving valid pairs (emoji, CJK ext.), applied to string messages, tool results, and text parts. - Leaked tool-call recovery: some backends stream a tool call as raw XML instead of a structured LanguageModelToolCallPart, leaving the turn with no tool_use block and stalling the task in a "no tools used" retry loop. extractLeakedToolCalls() and trailingPartialToolMarkerLength() detect the markup mid-stream (including markers split across chunk boundaries) and replay it as a real tool call, conservatively: only for names matching a tool actually offered that turn, and only when tools were offered. - Window-safe tool_result truncation: Copilot's backend trims over-window requests without preserving tool_use/tool_result pairing, orphaning a tool_result and causing a 400 (unexpected tool_use_id). truncateToolResultsToFitWindow() and middleOutTruncate() shrink oversized tool_result payloads on our side (largest first, middle-out, pairing preserved) before sending. Ported from simurg79/Roo-Code#12. --- src/api/providers/__tests__/vscode-lm.spec.ts | 197 +++++++++- src/api/providers/vscode-lm.ts | 340 +++++++++++++++++- .../__tests__/vscode-lm-format.spec.ts | 38 +- src/api/transform/vscode-lm-format.ts | 30 +- 4 files changed, 594 insertions(+), 11 deletions(-) diff --git a/src/api/providers/__tests__/vscode-lm.spec.ts b/src/api/providers/__tests__/vscode-lm.spec.ts index 37fb851720..5cd11ddb65 100644 --- a/src/api/providers/__tests__/vscode-lm.spec.ts +++ b/src/api/providers/__tests__/vscode-lm.spec.ts @@ -60,7 +60,13 @@ vi.mock("vscode", () => { }) import * as vscode from "vscode" -import { VsCodeLmHandler } from "../vscode-lm" +import { + VsCodeLmHandler, + extractLeakedToolCalls, + trailingPartialToolMarkerLength, + middleOutTruncate, + truncateToolResultsToFitWindow, +} from "../vscode-lm" import type { ApiHandlerOptions } from "../../../shared/api" import type { Anthropic } from "@anthropic-ai/sdk" import { openAiModelInfoSaneDefaults, vscodeLlmDefaultModelId, vscodeLlmModels } from "@roo-code/types" @@ -1075,3 +1081,192 @@ describe("VsCodeLmHandler", () => { }) }) }) + +describe("leaked tool-call recovery", () => { + // Builders keep the XML fixtures readable and prevent this file's own markup from being + // mistaken for a real tool call. + const invoke = (name: string, body: string) => `${body}` + const param = (name: string, value: string) => `${value}` + + describe("extractLeakedToolCalls", () => { + it("recovers a known-tool block and strips it from the leftover text", () => { + const text = `Working on it.\n${invoke("update_todo_list", param("todos", "[x] one\n[ ] two"))}` + + const { calls, leftoverText } = extractLeakedToolCalls(text, new Set(["update_todo_list"])) + + expect(calls).toEqual([{ name: "update_todo_list", input: { todos: "[x] one\n[ ] two" } }]) + expect(leftoverText).toBe("Working on it.\n") + }) + + it("recovers an unwrapped leak preceded by a stray token", () => { + const text = `court\n${invoke("update_todo_list", param("todos", "[x] done"))}` + + const { calls, leftoverText } = extractLeakedToolCalls(text, new Set(["update_todo_list"])) + + expect(calls).toEqual([{ name: "update_todo_list", input: { todos: "[x] done" } }]) + expect(leftoverText).toBe("court\n") + }) + + it("recovers multiple params and strips function-call wrapper tags", () => { + const body = param("mode", "code") + param("message", "go") + const text = `${invoke("new_task", body)}` + + const { calls, leftoverText } = extractLeakedToolCalls(text, new Set(["new_task"])) + + expect(calls).toEqual([{ name: "new_task", input: { mode: "code", message: "go" } }]) + expect(leftoverText).toBe("") + }) + + it("passes through invoke blocks for tools that were not offered", () => { + const text = invoke("some_other_tool", param("x", "1")) + + const { calls, leftoverText } = extractLeakedToolCalls(text, new Set(["update_todo_list"])) + + expect(calls).toEqual([]) + expect(leftoverText).toBe(text) + }) + + it("returns no calls for ordinary text", () => { + const { calls, leftoverText } = extractLeakedToolCalls("just a normal reply", new Set(["update_todo_list"])) + + expect(calls).toEqual([]) + expect(leftoverText).toBe("just a normal reply") + }) + }) + + describe("trailingPartialToolMarkerLength", () => { + it("holds back a split marker prefix at the end of a chunk", () => { + expect(trailingPartialToolMarkerLength("some text { + expect(trailingPartialToolMarkerLength("hello world")).toBe(0) + expect(trailingPartialToolMarkerLength("a < b")).toBe(0) + expect(trailingPartialToolMarkerLength("text ")).toBe(0) + }) + }) +}) + +describe("context-window tool_result truncation", () => { + describe("middleOutTruncate", () => { + it("returns text unchanged when within the limit", () => { + expect(middleOutTruncate("hello world", 100)).toBe("hello world") + }) + + it("keeps the head and tail and inserts a truncation marker", () => { + const text = "A".repeat(500) + "B".repeat(500) + const result = middleOutTruncate(text, 200) + + expect(result.length).toBeLessThanOrEqual(200) + expect(result).toContain("characters truncated to fit the model context window") + expect(result.startsWith("A")).toBe(true) + expect(result.endsWith("B")).toBe(true) + }) + + it("returns an empty string for a non-positive limit", () => { + expect(middleOutTruncate("anything", 0)).toBe("") + }) + }) + + describe("truncateToolResultsToFitWindow", () => { + const toolUseMessage = (id: string): Anthropic.Messages.MessageParam => ({ + role: "assistant", + content: [ + { type: "text", text: "Calling a tool." }, + { type: "tool_use", id, name: "some_tool", input: { a: 1 } }, + ], + }) + + const toolResultMessage = (id: string, content: string): Anthropic.Messages.MessageParam => ({ + role: "user", + content: [ + { type: "tool_result", tool_use_id: id, content }, + { type: "text", text: "env" }, + ], + }) + + const findBlock = (message: Anthropic.Messages.MessageParam, type: string) => + (message.content as unknown as Array<{ type: string; [key: string]: unknown }>).find( + (block) => block.type === type, + )! + + it("is a no-op when the conversation already fits the budget", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + toolUseMessage("t1"), + toolResultMessage("t1", "small result"), + ] + const before = JSON.parse(JSON.stringify(messages)) + + truncateToolResultsToFitWindow(messages, 100_000) + + expect(messages).toEqual(before) + }) + + it("shrinks an oversized tool_result so the conversation fits the budget", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + toolUseMessage("t1"), + toolResultMessage("t1", "X".repeat(50_000)), + ] + + truncateToolResultsToFitWindow(messages, 10_000) + + const toolResult = findBlock(messages[1], "tool_result") + expect(toolResult.tool_use_id).toBe("t1") // pairing preserved + expect(String(toolResult.content).length).toBeLessThanOrEqual(10_000) + expect(String(toolResult.content)).toContain("characters truncated") + }) + + it("truncates the largest tool_result first and leaves small ones intact", () => { + const small = "small but real result" + const messages: Anthropic.Messages.MessageParam[] = [ + toolUseMessage("t1"), + toolResultMessage("t1", "B".repeat(40_000)), + toolUseMessage("t2"), + toolResultMessage("t2", small), + ] + + truncateToolResultsToFitWindow(messages, 12_000) + + expect(String(findBlock(messages[1], "tool_result").content)).toContain("characters truncated") + expect(findBlock(messages[3], "tool_result").content).toBe(small) // untouched + }) + + it("never truncates tool_use blocks, assistant text, or environment details", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + toolUseMessage("t1"), + toolResultMessage("t1", "X".repeat(50_000)), + ] + + truncateToolResultsToFitWindow(messages, 8_000) + + expect(findBlock(messages[0], "text").text).toBe("Calling a tool.") + expect(findBlock(messages[0], "tool_use")).toMatchObject({ id: "t1", name: "some_tool" }) + expect(findBlock(messages[1], "text").text).toBe("env") + }) + + it("handles array-form tool_result content and keeps it valid", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + toolUseMessage("t1"), + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "t1", + content: [{ type: "text", text: "Y".repeat(40_000) }], + }, + ], + }, + ] + + truncateToolResultsToFitWindow(messages, 8_000) + + const toolResult = findBlock(messages[1], "tool_result") + expect(toolResult.tool_use_id).toBe("t1") + expect(Array.isArray(toolResult.content)).toBe(true) + const parts = toolResult.content as Array<{ type: string; text?: string }> + expect(parts[0].type).toBe("text") + expect(String(parts[0].text)).toContain("characters truncated") + }) + }) +}) diff --git a/src/api/providers/vscode-lm.ts b/src/api/providers/vscode-lm.ts index c657e6c0d6..d72779fbec 100644 --- a/src/api/providers/vscode-lm.ts +++ b/src/api/providers/vscode-lm.ts @@ -60,6 +60,251 @@ function convertToVsCodeLmTools(tools: OpenAI.Chat.ChatCompletionTool[]): vscode * } * ``` */ +/** + * Recovery for leaked tool calls + * ------------------------------ + * Some VS Code LM backends — notably GitHub Copilot serving Anthropic Claude models — + * intermittently stream a tool call as PLAIN TEXT using Anthropic's internal function-call + * XML instead of emitting a structured `LanguageModelToolCallPart`. When this happens the + * assistant turn contains no tool_use block, so Zoo reports "no tools used" and the task stalls + * in a retry loop. The helpers below detect the leaked markup mid-stream and replay it as a real + * tool call. Recovery is deliberately conservative: only `` blocks whose name matches a + * tool we actually offered this turn are treated as calls; everything else is passed through + * unchanged as text. + */ +const LEAKED_TOOL_CALL_START = /<(?:antml:)?(?:function_calls|invoke)\b/i +const LEAKED_INVOKE_BLOCK = /<(?:antml:)?invoke\s+name="([^"]+)"\s*>([\s\S]*?)<\/(?:antml:)?invoke\s*>/gi +const LEAKED_INVOKE_PARAM = /<(?:antml:)?parameter\s+name="([^"]+)"\s*>([\s\S]*?)<\/(?:antml:)?parameter\s*>/gi + +/** + * Returns the length of a trailing ` { + const input: Record = {} + LEAKED_INVOKE_PARAM.lastIndex = 0 + let match: RegExpExecArray | null + while ((match = LEAKED_INVOKE_PARAM.exec(body)) !== null) { + input[match[1]] = match[2].trim() + } + return input +} + +/** + * Extracts complete leaked `` tool-call blocks from `text`. Only blocks whose name + * is present in `validToolNames` are returned as calls; all other text (including `` + * blocks for unknown names) is returned as `leftoverText` so legitimate prose is preserved. + */ +export function extractLeakedToolCalls( + text: string, + validToolNames: ReadonlySet, +): { calls: Array<{ name: string; input: Record }>; leftoverText: string } { + const calls: Array<{ name: string; input: Record }> = [] + let leftover = "" + let lastIndex = 0 + + LEAKED_INVOKE_BLOCK.lastIndex = 0 + let match: RegExpExecArray | null + while ((match = LEAKED_INVOKE_BLOCK.exec(text)) !== null) { + leftover += text.slice(lastIndex, match.index) + const name = match[1] + if (validToolNames.has(name)) { + calls.push({ name, input: parseLeakedInvokeParams(match[2]) }) + } else { + // Not one of our tools — keep the block as literal text. + leftover += match[0] + } + lastIndex = match.index + match[0].length + } + leftover += text.slice(lastIndex) + + // Remove bare function-call wrapper tags left behind (cosmetic; also avoids re-teaching + // the model this format when the turn is later sent back as history). + leftover = leftover.replace(/<\/?(?:antml:)?function_calls\s*>/gi, "") + + return { calls, leftoverText: leftover } +} + +/** + * Context-window safety for Copilot's backend + * ------------------------------------------- + * Copilot's backend enforces its own context window and, for third-party `sendRequest` callers, + * trims an over-window request in a way that is NOT tool-pair-aware: it can drop the assistant + * message holding a `tool_use` while keeping the matching `tool_result`, after which Anthropic + * rejects the request with "unexpected tool_use_id". To keep trimming on OUR side — where + * pairing is preserved — we shrink oversized `tool_result` payloads before sending. Only + * `tool_result` text is truncated (never `tool_use`, assistant text, summaries, or environment + * details), and only when the request would otherwise exceed the budget. + */ + +/** + * Conservative characters-per-token ratio used to turn a token window into a character budget. + * The token-dense JSON, logs, and code that dominate oversized tool results tokenize to fewer + * characters per token than prose, so we intentionally under-count (3, not the ~4 typical of + * English) to keep the resulting budget on the safe side of the enforced window. + */ +const VSCODE_LM_BUDGET_CHARS_PER_TOKEN = 3 + +/** + * Fraction of the context window the *entire* input (system prompt + tool schemas + conversation) + * is allowed to occupy. The remaining headroom absorbs char/token estimation variance and any + * output/overhead the backend reserves. + */ +const VSCODE_LM_INPUT_BUDGET_FRACTION = 0.8 + +/** A tool_result is never shrunk below this many characters, so a truncated result stays useful. */ +const MIN_TOOL_RESULT_CHARS = 2000 + +function readToolResultText(block: Anthropic.Messages.ContentBlockParam): string | undefined { + if (!block || (block as { type?: string }).type !== "tool_result") { + return undefined + } + const content = (block as Anthropic.Messages.ToolResultBlockParam).content + if (typeof content === "string") { + return content + } + if (Array.isArray(content)) { + return content + .filter((part): part is Anthropic.Messages.TextBlockParam => (part as { type?: string })?.type === "text") + .map((part) => part.text ?? "") + .join("") + } + return undefined +} + +function writeToolResultText(block: Anthropic.Messages.ContentBlockParam, text: string): void { + const toolResult = block as Anthropic.Messages.ToolResultBlockParam + const content = toolResult.content + if (Array.isArray(content)) { + // Preserve any non-text parts (e.g. images) and collapse the text into one truncated part. + const nonText = content.filter((part) => (part as { type?: string })?.type !== "text") + toolResult.content = [{ type: "text", text }, ...nonText] as typeof content + return + } + toolResult.content = text +} + +/** + * Middle-out truncate `text` to at most `maxChars`, keeping the head and tail and replacing the + * middle with a marker noting how many characters were removed. Head/tail are preserved because + * logs and file dumps carry the most signal at their start (structure) and end (recent output). + */ +export function middleOutTruncate(text: string, maxChars: number): string { + if (maxChars <= 0) { + return "" + } + if (text.length <= maxChars) { + return text + } + + const buildMarker = (removed: number) => + `\n\n[... ${removed.toLocaleString("en-US")} characters truncated to fit the model context window ...]\n\n` + + // Reserve room for the marker, sized against the original length so the result never grows. + const reservedMarkerLength = buildMarker(text.length).length + const keep = Math.max(0, maxChars - reservedMarkerLength) + const headLength = Math.ceil(keep / 2) + const tailLength = keep - headLength + let head = text.slice(0, headLength) + // Don't end the head on a lone high surrogate — its low half is in the removed middle, and a lone + // surrogate cannot be encoded as UTF-8 (the backend 400s the whole request). Drop the split half. + if (head.length > 0 && (head.charCodeAt(head.length - 1) & 0xfc00) === 0xd800) { + head = head.slice(0, -1) + } + let tail = tailLength > 0 ? text.slice(text.length - tailLength) : "" + // Likewise, don't start the tail on a lone low surrogate (its high half is in the removed middle). + if (tail.length > 0 && (tail.charCodeAt(0) & 0xfc00) === 0xdc00) { + tail = tail.slice(1) + } + const removed = text.length - head.length - tail.length + return `${head}${buildMarker(removed)}${tail}` +} + +function estimateContentChars(content: Anthropic.Messages.MessageParam["content"]): number { + if (typeof content === "string") { + return content.length + } + if (!Array.isArray(content)) { + return 0 + } + let total = 0 + for (const block of content) { + const type = (block as { type?: string })?.type + if (type === "text") { + total += (block as Anthropic.Messages.TextBlockParam).text?.length ?? 0 + } else if (type === "tool_result") { + total += readToolResultText(block)?.length ?? 0 + } else if (type === "tool_use") { + total += JSON.stringify((block as Anthropic.Messages.ToolUseBlockParam).input ?? {}).length + } else if (type === "image") { + total += 8 // "[IMAGE]" placeholder — VS Code LM drops image data anyway. + } + } + return total +} + +/** + * Shrinks oversized `tool_result` payloads (largest first, middle-out) until the conversation fits + * `budgetChars`. Mutates the tool_result blocks of the supplied messages in place — callers pass a + * cloned array (see `createMessage`) so stored history is never mutated. A no-op when the + * conversation already fits. + */ +export function truncateToolResultsToFitWindow( + messages: Anthropic.Messages.MessageParam[], + budgetChars: number, +): Anthropic.Messages.MessageParam[] { + if (!Number.isFinite(budgetChars) || budgetChars <= 0) { + return messages + } + + let total = messages.reduce((sum, message) => sum + estimateContentChars(message.content), 0) + if (total <= budgetChars) { + return messages + } + + // Collect every truncatable tool_result block, largest first. + const toolResultBlocks: Anthropic.Messages.ContentBlockParam[] = [] + for (const message of messages) { + if (!Array.isArray(message.content)) { + continue + } + for (const block of message.content) { + if (readToolResultText(block) !== undefined) { + toolResultBlocks.push(block) + } + } + } + toolResultBlocks.sort((a, b) => (readToolResultText(b)?.length ?? 0) - (readToolResultText(a)?.length ?? 0)) + + for (const block of toolResultBlocks) { + if (total <= budgetChars) { + break + } + const text = readToolResultText(block) + if (text === undefined || text.length <= MIN_TOOL_RESULT_CHARS) { + continue + } + + const overage = total - budgetChars + const target = Math.max(MIN_TOOL_RESULT_CHARS, text.length - overage) + if (target >= text.length) { + continue + } + + const truncated = middleOutTruncate(text, target) + total -= text.length - truncated.length + writeToolResultText(block, truncated) + } + + return messages +} + export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHandler { protected options: ApiHandlerOptions private client: vscode.LanguageModelChat | null @@ -383,6 +628,19 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan content: this.cleanMessageContent(msg.content), })) + // Keep context-window trimming on OUR side. Copilot's backend trims an over-window request + // without preserving tool_use/tool_result pairing, which orphans a tool_result and triggers a + // 400 ("unexpected tool_use_id"). See truncateToolResultsToFitWindow. + const contextWindowTokens = this.getCondenseContextWindow() + if (Number.isFinite(contextWindowTokens) && contextWindowTokens > 0) { + const toolSchemaChars = metadata?.tools ? JSON.stringify(metadata.tools).length : 0 + const messagesBudgetChars = + contextWindowTokens * VSCODE_LM_INPUT_BUDGET_FRACTION * VSCODE_LM_BUDGET_CHARS_PER_TOKEN - + systemPrompt.length - + toolSchemaChars + truncateToolResultsToFitWindow(cleanedMessages, messagesBudgetChars) + } + // Convert Anthropic messages to VS Code LM messages const vsCodeLmMessages: vscode.LanguageModelChatMessage[] = [ vscode.LanguageModelChatMessage.Assistant(systemPrompt), @@ -398,6 +656,20 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan // Accumulate the text and count at the end of the stream to reduce token counting overhead. let accumulatedText: string = "" + // Leaked tool-call recovery state (see `extractLeakedToolCalls`). Only enabled when we + // actually offered tools this turn, so it can never misfire on plain conversations. + const providedToolNames = new Set( + (metadata?.tools ?? []) + .filter((tool) => tool.type === "function") + .map((tool) => tool.function.name) + .filter((name) => name.length > 0), + ) + const salvageLeakedToolCalls = providedToolNames.size > 0 + let salvageBuffering = false + let salvageBuffer = "" + let salvageCarry = "" + let salvagedToolCallIndex = 0 + try { // Create the response stream with required options const requestOptions: vscode.LanguageModelChatRequestOptions = { @@ -421,9 +693,40 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan } accumulatedText += chunk.value - yield { - type: "text", - text: chunk.value, + + // Fast path: when we didn't offer any tools there is nothing to salvage, so + // stream the text straight through exactly as before. + if (!salvageLeakedToolCalls) { + yield { type: "text", text: chunk.value } + continue + } + + // Once we've seen the start of a leaked tool-call block, buffer the rest of the + // stream so the full markup can be parsed and replayed as a structured call. + if (salvageBuffering) { + salvageBuffer += chunk.value + continue + } + + // Watch for the start of a leaked tool-call block, carrying a small tail across + // chunks so a marker split across chunk boundaries is still detected. + const combined = salvageCarry + chunk.value + const markerMatch = combined.match(LEAKED_TOOL_CALL_START) + if (markerMatch) { + const before = combined.slice(0, markerMatch.index) + if (before) { + yield { type: "text", text: before } + } + salvageBuffering = true + salvageBuffer = combined.slice(markerMatch.index) + salvageCarry = "" + } else { + const carryLength = trailingPartialToolMarkerLength(combined) + const emit = carryLength > 0 ? combined.slice(0, combined.length - carryLength) : combined + salvageCarry = carryLength > 0 ? combined.slice(combined.length - carryLength) : "" + if (emit) { + yield { type: "text", text: emit } + } } } else if (chunk instanceof vscode.LanguageModelToolCallPart) { try { @@ -472,6 +775,37 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan } } + // Flush any leaked tool-call recovery state accumulated during streaming. + if (salvageLeakedToolCalls) { + // A carried tail that never became a marker is just ordinary text. + if (!salvageBuffering && salvageCarry) { + yield { type: "text", text: salvageCarry } + } + + if (salvageBuffering && salvageBuffer) { + const { calls, leftoverText } = extractLeakedToolCalls(salvageBuffer, providedToolNames) + + // Emit surrounding prose first so recovered tool calls come last, matching the + // ordering of a normal native tool-calling turn. + if (leftoverText) { + yield { type: "text", text: leftoverText } + } + + for (const call of calls) { + console.warn( + "Zoo Code : Recovered a tool call the model emitted as text instead of a structured tool call:", + { name: call.name, params: Object.keys(call.input) }, + ) + yield { + type: "tool_call", + id: `vscodelm-salvaged-${Date.now()}-${salvagedToolCallIndex++}`, + name: call.name, + arguments: JSON.stringify(call.input), + } + } + } + } + // Count tokens in the accumulated text after stream completion const totalOutputTokens: number = await this.internalCountTokens(accumulatedText) diff --git a/src/api/transform/__tests__/vscode-lm-format.spec.ts b/src/api/transform/__tests__/vscode-lm-format.spec.ts index 3265f2745b..18bac948e3 100644 --- a/src/api/transform/__tests__/vscode-lm-format.spec.ts +++ b/src/api/transform/__tests__/vscode-lm-format.spec.ts @@ -3,7 +3,12 @@ import { Anthropic } from "@anthropic-ai/sdk" import * as vscode from "vscode" -import { convertToVsCodeLmMessages, convertToAnthropicRole, extractTextCountFromMessage } from "../vscode-lm-format" +import { + convertToVsCodeLmMessages, + convertToAnthropicRole, + extractTextCountFromMessage, + sanitizeSurrogates, +} from "../vscode-lm-format" // Mock crypto using Vitest vitest.stubGlobal("crypto", { @@ -325,6 +330,37 @@ describe("convertToVsCodeLmMessages", () => { }) }) +describe("sanitizeSurrogates", () => { + it("leaves plain ASCII unchanged", () => { + expect(sanitizeSurrogates("hello world")).toBe("hello world") + }) + + it("leaves valid surrogate pairs unchanged", () => { + // 😀 U+1F600 and 𐀀 U+10000 are astral-plane code points encoded as surrogate pairs. + expect(sanitizeSurrogates("a\uD83D\uDE00b\uD800\uDC00c")).toBe("a\uD83D\uDE00b\uD800\uDC00c") + }) + + it("replaces a lone high surrogate with U+FFFD", () => { + expect(sanitizeSurrogates("a\uD800b")).toBe("a\uFFFDb") + }) + + it("replaces a lone low surrogate with U+FFFD", () => { + expect(sanitizeSurrogates("a\uDC00b")).toBe("a\uFFFDb") + }) + + it("replaces a trailing lone high surrogate", () => { + expect(sanitizeSurrogates("abc\uD800")).toBe("abc\uFFFD") + }) + + it("replaces a reversed (low-then-high) pair as two lone surrogates", () => { + expect(sanitizeSurrogates("\uDC00\uD800")).toBe("\uFFFD\uFFFD") + }) + + it("returns empty input unchanged", () => { + expect(sanitizeSurrogates("")).toBe("") + }) +}) + describe("convertToAnthropicRole", () => { it("should convert assistant role correctly", () => { const result = convertToAnthropicRole(vscode.LanguageModelChatMessageRole.Assistant) diff --git a/src/api/transform/vscode-lm-format.ts b/src/api/transform/vscode-lm-format.ts index 7ac51e024f..a03cba257c 100644 --- a/src/api/transform/vscode-lm-format.ts +++ b/src/api/transform/vscode-lm-format.ts @@ -28,6 +28,23 @@ function asObjectSafe(value: unknown): object { } } +/** + * Replaces unpaired UTF-16 surrogate code units with the Unicode replacement character (U+FFFD). + * + * The VS Code LM backend forwards requests to model APIs that require valid UTF-8. A lone surrogate + * — e.g. left behind when some upstream step slices a string through an astral-plane character + * (emoji, CJK extension, etc.) — cannot be encoded as UTF-8, so the backend rejects the entire + * request with a 400 ("string contains an unpaired UTF-16 surrogate code point and cannot be + * encoded as valid UTF-8"). Valid surrogate pairs are matched by the lookahead/lookbehind and left + * untouched. The regex intentionally omits the `u` flag so it operates on UTF-16 code units. + */ +export function sanitizeSurrogates(text: string): string { + if (!text) { + return text + } + return text.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { if (part.type === "image") { if (part.source.type === "base64") { @@ -82,7 +100,7 @@ export function convertToVsCodeLmMessages( ) } if (part.type === "text") { - return new vscode.LanguageModelTextPart(part.text) + return new vscode.LanguageModelTextPart(sanitizeSurrogates(part.text)) } return new vscode.LanguageModelTextPart("") }) ?? [new vscode.LanguageModelTextPart("")]) @@ -102,7 +120,7 @@ export function convertToVsCodeLmMessages( `[Image (${part.source.type}): not supported by VSCode LM API]`, ) } - return new vscode.LanguageModelTextPart(part.text) + return new vscode.LanguageModelTextPart(sanitizeSurrogates(part.text)) }), ] @@ -135,7 +153,7 @@ export function convertToVsCodeLmMessages( if (part.type === "image") { return new vscode.LanguageModelTextPart("[Image generation not supported by VSCode LM API]") } - return new vscode.LanguageModelTextPart(part.text) + return new vscode.LanguageModelTextPart(sanitizeSurrogates(part.text)) }), // Convert tool messages to ToolCallParts after text From 306976d4d4470f3daf786ea3bd32635107a2c60e Mon Sep 17 00:00:00 2001 From: Bertan Ari Date: Fri, 7 Aug 2026 18:04:13 -0700 Subject: [PATCH 2/2] test(vscode-lm): cover leaked tool-call salvage and tool_result truncation paths Raises patch coverage on the new vscode-lm reliability code above the 80%% codecov/patch gate by exercising the streaming salvage state machine (marker split across chunks, multi-chunk buffering, unknown-tool passthrough, carried tail) and the tool_result truncation helpers (array-form content, surrogate-safe middle-out, guard clauses). --- src/api/providers/__tests__/vscode-lm.spec.ts | 216 ++++++++++++++++++ 1 file changed, 216 insertions(+) diff --git a/src/api/providers/__tests__/vscode-lm.spec.ts b/src/api/providers/__tests__/vscode-lm.spec.ts index 5cd11ddb65..69743f18ee 100644 --- a/src/api/providers/__tests__/vscode-lm.spec.ts +++ b/src/api/providers/__tests__/vscode-lm.spec.ts @@ -276,6 +276,108 @@ describe("VsCodeLmHandler", () => { }) }) + describe("leaked tool-call recovery during streaming", () => { + const salvageTools = [ + { + type: "function" as const, + function: { + name: "calculator", + description: "A simple calculator", + parameters: { type: "object", properties: { operation: { type: "string" } } }, + }, + }, + ] + + const streamTextParts = (parts: string[]) => { + mockLanguageModelChat.sendRequest.mockResolvedValueOnce({ + stream: (async function* () { + for (const part of parts) { + yield new vscode.LanguageModelTextPart(part) + } + return + })(), + text: (async function* () { + yield parts.join("") + return + })(), + }) + } + + const collect = async (parts: string[]) => { + streamTextParts(parts) + const stream = handler.createMessage("system", [{ role: "user" as const, content: "hi" }], { + taskId: "test-task", + tools: salvageTools, + }) + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + return chunks + } + + it("recovers a tool call the model streamed as raw invoke XML", async () => { + const chunks = await collect([ + "Thinking. ", + 'add', + ]) + + expect(chunks.filter((chunk) => chunk.type === "text")).toEqual([{ type: "text", text: "Thinking. " }]) + expect(chunks.filter((chunk) => chunk.type === "tool_call")).toEqual([ + { + type: "tool_call", + id: expect.stringContaining("vscodelm-salvaged-"), + name: "calculator", + arguments: JSON.stringify({ operation: "add" }), + }, + ]) + }) + + it("detects a marker split across stream chunks", async () => { + const chunks = await collect([ + "abc sub', + ]) + + expect(chunks.filter((chunk) => chunk.type === "text")).toEqual([{ type: "text", text: "abc " }]) + expect(chunks.filter((chunk) => chunk.type === "tool_call")).toMatchObject([ + { name: "calculator", arguments: JSON.stringify({ operation: "sub" }) }, + ]) + }) + + it("emits a carried tail as plain text when it never becomes a marker", async () => { + const chunks = await collect(["hello chunk.type === "text")).toEqual([ + { type: "text", text: "hello " }, + { type: "text", text: " chunk.type === "tool_call")).toBe(false) + }) + + it("buffers across chunks that arrive after the marker", async () => { + const chunks = await collect([ + 'prose ', + '', + "mul", + "", + ]) + + expect(chunks.filter((chunk) => chunk.type === "text")).toEqual([{ type: "text", text: "prose " }]) + expect(chunks.filter((chunk) => chunk.type === "tool_call")).toMatchObject([ + { name: "calculator", arguments: JSON.stringify({ operation: "mul" }) }, + ]) + }) + + it("keeps an invoke block for an unknown tool as literal text", async () => { + const block = '1' + const chunks = await collect([block]) + + expect(chunks.filter((chunk) => chunk.type === "text")).toEqual([{ type: "text", text: block }]) + expect(chunks.some((chunk) => chunk.type === "tool_call")).toBe(false) + }) + }) + it("should handle native tool calls when tools are provided", async () => { const systemPrompt = "You are a helpful assistant" const messages: Anthropic.Messages.MessageParam[] = [ @@ -1166,6 +1268,15 @@ describe("context-window tool_result truncation", () => { it("returns an empty string for a non-positive limit", () => { expect(middleOutTruncate("anything", 0)).toBe("") }) + + // A lone surrogate cannot be encoded as UTF-8 and 400s the whole request. + it("never splits a surrogate pair across the removed middle", () => { + const pair = "\u{1F600}" // one astral char = high + low surrogate + const text = pair.repeat(400) + const result = middleOutTruncate(text, 200) + + expect(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { @@ -1202,6 +1313,111 @@ describe("context-window tool_result truncation", () => { expect(messages).toEqual(before) }) + it("returns messages untouched when the budget is not a usable number", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + toolUseMessage("t1"), + toolResultMessage("t1", "Y".repeat(50_000)), + ] + const before = JSON.parse(JSON.stringify(messages)) + + expect(truncateToolResultsToFitWindow(messages, 0)).toBe(messages) + expect(truncateToolResultsToFitWindow(messages, Number.NaN)).toBe(messages) + expect(messages).toEqual(before) + }) + + it("truncates array-form tool_result content and preserves non-text parts", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + toolUseMessage("t1"), + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "t1", + content: [ + { type: "text", text: "Z".repeat(50_000) }, + { type: "image", source: { type: "base64", media_type: "image/png", data: "abc" } }, + ], + }, + ], + } as unknown as Anthropic.Messages.MessageParam, + ] + + truncateToolResultsToFitWindow(messages, 10_000) + + const toolResult = findBlock(messages[1], "tool_result") + const parts = toolResult.content as Array<{ type: string; text?: string }> + expect(parts[0].type).toBe("text") + expect(parts[0].text).toContain("characters truncated") + expect(parts.some((part) => part.type === "image")).toBe(true) + }) + + it("ignores string content and skips messages that cannot hold tool_result blocks", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: "a plain string turn" }, + toolUseMessage("t1"), + { + role: "user", + content: [ + { type: "tool_result", tool_use_id: "t1", content: "W".repeat(50_000) }, + { type: "image", source: { type: "base64", media_type: "image/png", data: "abc" } }, + ], + } as unknown as Anthropic.Messages.MessageParam, + ] + + truncateToolResultsToFitWindow(messages, 10_000) + + expect(messages[0].content).toBe("a plain string turn") + expect(String(findBlock(messages[2], "tool_result").content)).toContain("characters truncated") + }) + + it("ignores a tool_result whose content is neither string nor array", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + toolUseMessage("t1"), + toolResultMessage("t1", "V".repeat(50_000)), + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "t2", content: undefined }], + } as unknown as Anthropic.Messages.MessageParam, + ] + + truncateToolResultsToFitWindow(messages, 10_000) + + expect(findBlock(messages[2], "tool_result").content).toBeUndefined() + expect(String(findBlock(messages[1], "tool_result").content)).toContain("characters truncated") + }) + + it("skips a tool_result already small enough to need no trimming", () => { + // Overage is tiny, so the largest block's target lands at its current length. + const messages: Anthropic.Messages.MessageParam[] = [ + toolUseMessage("t1"), + toolResultMessage("t1", "U".repeat(3000)), + toolUseMessage("t2"), + toolResultMessage("t2", "T".repeat(2500)), + ] + + truncateToolResultsToFitWindow(messages, 5600) + + const first = String(findBlock(messages[1], "tool_result").content) + const second = String(findBlock(messages[3], "tool_result").content) + expect(first.length + second.length).toBeLessThanOrEqual(5600) + }) + + it("leaves a tool_result at or below the minimum size alone", () => { + const shortResult = "S".repeat(1500) + const messages: Anthropic.Messages.MessageParam[] = [ + toolUseMessage("t1"), + toolResultMessage("t1", shortResult), + toolUseMessage("t2"), + toolResultMessage("t2", shortResult), + ] + + truncateToolResultsToFitWindow(messages, 100) + + expect(findBlock(messages[1], "tool_result").content).toBe(shortResult) + expect(findBlock(messages[3], "tool_result").content).toBe(shortResult) + }) + it("shrinks an oversized tool_result so the conversation fits the budget", () => { const messages: Anthropic.Messages.MessageParam[] = [ toolUseMessage("t1"),