diff --git a/src/api/providers/__tests__/vscode-lm.spec.ts b/src/api/providers/__tests__/vscode-lm.spec.ts
index 7434b4de45d..be64689a2da 100644
--- a/src/api/providers/__tests__/vscode-lm.spec.ts
+++ b/src/api/providers/__tests__/vscode-lm.spec.ts
@@ -56,7 +56,13 @@ vi.mock("vscode", () => {
import * as vscode from "vscode"
import { openAiModelInfoSaneDefaults, vscodeLlmModels } from "@roo-code/types"
-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"
@@ -593,3 +599,193 @@ describe("VsCodeLmHandler", () => {
})
})
})
+
+describe("leaked tool-call recovery", () => {
+ // Builders keep the XML fixtures readable while avoiding giant inline string literals.
+ 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 the real-world 'court'-prefixed, unwrapped leak", () => {
+ // Copilot/Claude sometimes streams the call as text with a stray leading token and
+ // no wrapper — the exact shape observed in the field.
+ 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 d730658b446..7500c2b13ee 100644
--- a/src/api/providers/vscode-lm.ts
+++ b/src/api/providers/vscode-lm.ts
@@ -33,6 +33,266 @@ 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`. The leaked text looks
+ * like this (sometimes without the outer `` wrapper and often prefixed by a
+ * stray decode-artifact token, which is what makes the upstream parser miss it):
+ *
+ *
+ * [x] ...
+ *
+ *
+ * When this happens the assistant turn contains no tool_use block, so Roo 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.
+ * Bare `` wrapper tags are stripped from the leftover text.
+ */
+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
+ * -------------------------------------------
+ * When Roo sends messages through the VS Code LM API, GitHub Copilot's backend enforces its own
+ * context window. For third-party `sendRequest` callers it 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
+ * "messages.0.content.0: unexpected tool_use_id ... Each tool_result block must have a
+ * corresponding tool_use block in the previous message." A single oversized MCP tool result
+ * (e.g. a large build log or file dump) in the most-recent turn is enough to push a freshly
+ * condensed request over the window — and condensing cannot shrink the newest turn.
+ *
+ * To keep trimming on OUR side — where tool_use/tool_result pairing is preserved — we shrink
+ * oversized `tool_result` payloads before sending so the request fits the window. 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, so normal-sized
+ * requests are left untouched.
+ */
+
+/**
+ * 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. Returns the same array for
+ * convenience. 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
+}
+
/**
* Handles interaction with VS Code's Language Model API for chat-based operations.
* This handler extends BaseProvider to provide VS Code LM specific functionality.
@@ -377,6 +637,20 @@ 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 ("messages.0.content.0: unexpected tool_use_id"). Shrink oversized tool_result payloads
+ // so the request fits the window before we hand it off. 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),
@@ -392,6 +666,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 = {
@@ -415,9 +703,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 {
@@ -466,6 +785,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(
+ "Roo 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 e60860b5491..b3b1e000b65 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", {
@@ -174,6 +179,65 @@ describe("convertToVsCodeLmMessages", () => {
const imagePlaceholder = result[0].content[1] as MockLanguageModelTextPart
expect(imagePlaceholder.value).toContain("[Image (base64): image/png not supported by VSCode LM API]")
})
+
+ it("should replace an unpaired surrogate in a tool_result with U+FFFD", () => {
+ const messages: Anthropic.Messages.MessageParam[] = [
+ {
+ role: "user",
+ content: [
+ {
+ type: "tool_result",
+ tool_use_id: "tool-1",
+ content: `head\uD800tail`,
+ },
+ ],
+ },
+ ]
+
+ const result = convertToVsCodeLmMessages(messages)
+
+ const toolResult = result[0].content[0] as MockLanguageModelToolResultPart
+ expect(toolResult.content[0].value).toBe(`head\uFFFDtail`)
+ })
+
+ it("should keep a valid surrogate pair (emoji) intact through conversion", () => {
+ const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: `hi \uD83D\uDE00` }]
+
+ const result = convertToVsCodeLmMessages(messages)
+
+ expect((result[0].content[0] as MockLanguageModelTextPart).value).toBe(`hi \uD83D\uDE00`)
+ })
+})
+
+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 and non-surrogate input unchanged", () => {
+ expect(sanitizeSurrogates("")).toBe("")
+ })
})
describe("convertToAnthropicRole", () => {
diff --git a/src/api/transform/vscode-lm-format.ts b/src/api/transform/vscode-lm-format.ts
index 388197c2c2c..f02d16cceec 100644
--- a/src/api/transform/vscode-lm-format.ts
+++ b/src/api/transform/vscode-lm-format.ts
@@ -28,6 +28,23 @@ function asObjectSafe(value: any): 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") {
return new vscode.LanguageModelTextPart(
`[Image (${part.source?.type || "Unknown source-type"}): ${part.source?.media_type || "unknown media-type"} not supported by VSCode LM API]`,
)
}
- return new vscode.LanguageModelTextPart(part.text)
+ return new vscode.LanguageModelTextPart(sanitizeSurrogates(part.text))
}) ?? [new vscode.LanguageModelTextPart("")])
return new vscode.LanguageModelToolResultPart(toolMessage.tool_use_id, toolContentParts)
@@ -89,7 +107,7 @@ export function convertToVsCodeLmMessages(
`[Image (${part.source?.type || "Unknown source-type"}): ${part.source?.media_type || "unknown media-type"} not supported by VSCode LM API]`,
)
}
- return new vscode.LanguageModelTextPart(part.text)
+ return new vscode.LanguageModelTextPart(sanitizeSurrogates(part.text))
}),
]
@@ -122,7 +140,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
diff --git a/src/core/diff/strategies/multi-search-replace.ts b/src/core/diff/strategies/multi-search-replace.ts
index f43bbee0dc9..98cca973db6 100644
--- a/src/core/diff/strategies/multi-search-replace.ts
+++ b/src/core/diff/strategies/multi-search-replace.ts
@@ -337,6 +337,12 @@ export class MultiSearchReplaceDiffStrategy implements DiffStrategy {
// Validate that search and replace content are not identical
if (searchContent === replaceContent) {
+ // [apply_diff diagnostics] TEMP: confirm no-op block detection in multi-block diffs.
+ console.warn(
+ `[apply_diff diagnostics] identical SEARCH/REPLACE block` +
+ ` startLine=${replacement.startLine}` +
+ ` searchPreview=${JSON.stringify(searchContent.slice(0, 80))}`,
+ )
diffResults.push({
success: false,
error:
@@ -507,6 +513,13 @@ export class MultiSearchReplaceDiffStrategy implements DiffStrategy {
appliedCount++
}
const finalContent = resultLines.join(lineEnding)
+ // [apply_diff diagnostics] TEMP: confirm appliedCount/failParts at the all-or-nothing gate.
+ console.warn(
+ `[apply_diff diagnostics] applyDiff summary` +
+ ` matches=${matches.length}` +
+ ` appliedCount=${appliedCount}` +
+ ` failParts=${diffResults.length}`,
+ )
if (appliedCount === 0) {
return {
success: false,
diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts
index c4a8927e826..5c6aef9e046 100644
--- a/src/core/task/Task.ts
+++ b/src/core/task/Task.ts
@@ -3486,6 +3486,27 @@ export class Task extends EventEmitter implements TaskLike {
// Increment consecutive no-tool-use counter
this.consecutiveNoToolUseCount++
+ // Diagnostic logging for the "no tools used" path. This most often happens when a
+ // provider (e.g. vscode-lm) surfaces the model's tool call as plain text/XML markup
+ // (e.g. a leaked `` block) instead of a structured tool_use block,
+ // so we capture the assistant text and a heuristic flag to make the failure diagnosable.
+ const assistantText = this.assistantMessageContent
+ .map((block) => (block.type === "text" ? block.content : ""))
+ .join("")
+ const looksLikeToolCallAsText = /<(invoke|function_calls|tool_call|parameter)\b/i.test(
+ assistantText,
+ )
+ console.warn(`[Task#${this.taskId}.${this.instanceId}] Model did not use a tool`, {
+ consecutiveNoToolUseCount: this.consecutiveNoToolUseCount,
+ provider: this.apiConfiguration.apiProvider ?? "unknown",
+ model: this.api.getModel().id,
+ blockTypes: this.assistantMessageContent.map((block) => block.type),
+ assistantTextLength: assistantText.length,
+ // Strong signal the model emitted a tool call as text rather than a structured tool_use block.
+ looksLikeToolCallAsText,
+ assistantTextPreview: assistantText.slice(0, 2000),
+ })
+
// Only show error and count toward mistake limit after 2 consecutive failures
if (this.consecutiveNoToolUseCount >= 2) {
await this.say("error", "MODEL_NO_TOOLS_USED")
diff --git a/src/core/tools/ApplyDiffTool.ts b/src/core/tools/ApplyDiffTool.ts
index 24968c0451e..b73334833a0 100644
--- a/src/core/tools/ApplyDiffTool.ts
+++ b/src/core/tools/ApplyDiffTool.ts
@@ -86,6 +86,15 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> {
let formattedError = ""
if (diffResult.failParts && diffResult.failParts.length > 0) {
+ // [apply_diff diagnostics] TEMP: how many failParts exist, and how many are surfaced.
+ const failedParts = diffResult.failParts.filter((p) => !p.success)
+ console.warn(
+ `[apply_diff diagnostics] surfacing failParts` +
+ ` total=${diffResult.failParts.length}` +
+ ` failed=${failedParts.length}` +
+ ` (only the LAST failed message is shown to the model)`,
+ )
+
for (const failPart of diffResult.failParts) {
if (failPart.success) {
continue
diff --git a/src/package.json b/src/package.json
index b8c4d45ec45..420be82a1bd 100644
--- a/src/package.json
+++ b/src/package.json
@@ -3,7 +3,7 @@
"displayName": "%extension.displayName%",
"description": "%extension.description%",
"publisher": "RooVeterinaryInc",
- "version": "3.53.1",
+ "version": "3.53.2",
"icon": "assets/icons/icon.png",
"galleryBanner": {
"color": "#617A91",