Skip to content

fix(vscode-lm): sanitize surrogates, recover leaked tool calls, and window-safe tool_result truncation - #1188

Open
simurg79 wants to merge 2 commits into
Zoo-Code-Org:mainfrom
simurg79:port/vscode-lm-reliability
Open

fix(vscode-lm): sanitize surrogates, recover leaked tool calls, and window-safe tool_result truncation#1188
simurg79 wants to merge 2 commits into
Zoo-Code-Org:mainfrom
simurg79:port/vscode-lm-reliability

Conversation

@simurg79

@simurg79 simurg79 commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Port of simurg79/Roo-Code#12 into this repo. Credit to the original PR author.

What this changes

Hardens the VS Code Language Model provider (notably GitHub Copilot serving Anthropic Claude) against three failure modes.

1. 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.

2. Leaked tool-call recovery

Some backends stream a tool call as raw <invoke> 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. This is deliberately conservative: only for <invoke> names matching a tool actually offered that turn, and only when tools were offered at all.

3. 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.

Adaptations made during the port

  • vscode-lm-format.ts had diverged from upstream, so insertion points were re-derived against the local structure.
  • Log strings rebranded to "Zoo Code".
  • The upstream PR's TEMP console.warn diagnostics (Task.ts, multi-search-replace.ts, ApplyDiffTool.ts) and its 3.53.1 -> 3.53.2 version bump were deliberately excluded.

Verification

  • Vitest on the two specs: 25 passing vs. 3 on the main baseline. All 22 new tests pass and no previously-passing test regressed. The 72 failures are pre-existing and identical to baseline (broken vscode mocks in those specs, out of scope).
  • ESLint with --prune-suppressions clean on all four changed files. src/eslint-suppressions.json verified content-identical and left unmodified.
  • tsc --noEmit shows no new type errors (only the 3 pre-existing ones already present on main).

Files changed

  • src/api/transform/vscode-lm-format.ts
  • src/api/providers/vscode-lm.ts
  • src/api/transform/__tests__/vscode-lm-format.spec.ts
  • src/api/providers/__tests__/vscode-lm.spec.ts

No changeset file is included.

Summary by CodeRabbit

  • Bug Fixes
    • Improved compatibility with VS Code Language Model providers that return tool calls as text.
    • Preserved tool-call data across streamed response chunks, including partial markers.
    • Prevented oversized tool results from exceeding context limits while retaining essential content.
    • Replaced invalid text characters with safe replacement characters to avoid malformed messages.
  • Tests
    • Added coverage for tool-call recovery, truncation behavior, streaming edge cases, and text encoding validation.

…indow-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 <invoke>
  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 <invoke> 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.
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f24a5975-43bd-42b6-b5dc-0b8c159fb663

📥 Commits

Reviewing files that changed from the base of the PR and between b4e1727 and 306976d.

📒 Files selected for processing (1)
  • src/api/providers/__tests__/vscode-lm.spec.ts

📝 Walkthrough

Walkthrough

The VS Code LM provider now recovers text-emitted tool calls, trims oversized tool results before requests, and sanitizes invalid surrogate characters during message conversion. Tests cover parsing, truncation, context limits, and surrogate handling.

Changes

VS Code LM robustness

Layer / File(s) Summary
Surrogate sanitization
src/api/transform/vscode-lm-format.ts, src/api/transform/__tests__/vscode-lm-format.spec.ts
sanitizeSurrogates replaces unpaired UTF-16 surrogates with U+FFFD. Message, tool-result, user, and assistant text conversion applies this sanitization.
Leaked tool-call recovery
src/api/providers/vscode-lm.ts, src/api/providers/__tests__/vscode-lm.spec.ts
Streaming output buffers partial <invoke> markup, validates tool names, preserves unsupported or ordinary text, and emits structured tool-call events for recognized calls.
Tool-result context trimming
src/api/providers/vscode-lm.ts, src/api/providers/__tests__/vscode-lm.spec.ts
The provider calculates an input character budget and applies middle-out truncation to oversized tool results while preserving structure and non-text content.

Estimated code review effort: 4 (Complex) | ~45 minutes

Suggested reviewers: edelauna

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant createMessage
  participant VSCodeLM
  participant extractLeakedToolCalls
  Client->>createMessage: message request
  createMessage->>createMessage: truncate oversized tool results
  createMessage->>VSCodeLM: converted messages
  VSCodeLM-->>createMessage: streamed text chunks
  createMessage->>extractLeakedToolCalls: buffered invoke markup
  extractLeakedToolCalls-->>createMessage: prose and validated tool calls
  createMessage-->>Client: text and tool_call events
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description explains the implementation and testing, but it omits the required issue link and pre-submission checklist. Add the approved GitHub issue number and complete the pre-submission checklist, including documentation impact and contribution guideline confirmation.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the three primary VS Code Language Model fixes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

src/api/providers/__tests__/vscode-lm.spec.ts

ESLint skipped: missing config or dependency (missing-dependency). The ESLint configuration references a package that is not available in the sandbox.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/api/transform/__tests__/vscode-lm-format.spec.ts (1)

333-363: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Test the conversion boundary.

These tests only exercise sanitizeSurrogates. They do not prove that convertToVsCodeLmMessages sanitizes simple message strings, tool-result strings, tool-result text blocks, user text blocks, and assistant text blocks.

Add converter unit tests that inspect the resulting VS Code text-part values for each changed path. As per coding guidelines, “Place tests in the narrowest layer that proves the behavior.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/transform/__tests__/vscode-lm-format.spec.ts` around lines 333 - 363,
Add unit tests for convertToVsCodeLmMessages that verify surrogate sanitization
in each affected conversion path: simple message strings, tool-result strings,
tool-result text blocks, user text blocks, and assistant text blocks. Assert the
resulting VS Code text-part values contain replacement characters for lone
surrogates, while keeping sanitizeSurrogates tests focused on the helper’s
direct behavior.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/api/transform/vscode-lm-format.ts`:
- Around line 41-46: Update the systemPrompt handling in the VS Code provider
before constructing LanguageModelChatMessage.Assistant so it passes through
sanitizeSurrogates, while preserving existing behavior for valid prompts. Add a
provider regression test covering a systemPrompt containing a lone surrogate and
verify the constructed request uses the replacement character.

---

Nitpick comments:
In `@src/api/transform/__tests__/vscode-lm-format.spec.ts`:
- Around line 333-363: Add unit tests for convertToVsCodeLmMessages that verify
surrogate sanitization in each affected conversion path: simple message strings,
tool-result strings, tool-result text blocks, user text blocks, and assistant
text blocks. Assert the resulting VS Code text-part values contain replacement
characters for lone surrogates, while keeping sanitizeSurrogates tests focused
on the helper’s direct behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: fd5d6dfc-37c2-454f-abcf-c73712c01f83

📥 Commits

Reviewing files that changed from the base of the PR and between 276e425 and b4e1727.

📒 Files selected for processing (4)
  • src/api/providers/__tests__/vscode-lm.spec.ts
  • src/api/providers/vscode-lm.ts
  • src/api/transform/__tests__/vscode-lm-format.spec.ts
  • src/api/transform/vscode-lm-format.ts

Comment on lines +41 to +46
export function sanitizeSurrogates(text: string): string {
if (!text) {
return text
}
return text.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "\uFFFD")
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Sanitize systemPrompt at the VS Code API boundary.

convertToVsCodeLmMessages only processes messages. src/api/providers/vscode-lm.ts creates LanguageModelChatMessage.Assistant(systemPrompt) directly. If systemPrompt contains a lone surrogate, the VS Code LM backend can still reject the request.

Sanitize systemPrompt before constructing that assistant message. Add a provider regression test for this path.

Proposed fix
- vscode.LanguageModelChatMessage.Assistant(systemPrompt),
+ vscode.LanguageModelChatMessage.Assistant(sanitizeSurrogates(systemPrompt)),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/transform/vscode-lm-format.ts` around lines 41 - 46, Update the
systemPrompt handling in the VS Code provider before constructing
LanguageModelChatMessage.Assistant so it passes through sanitizeSurrogates,
while preserving existing behavior for valid prompts. Add a provider regression
test covering a systemPrompt containing a lone surrogate and verify the
constructed request uses the replacement character.

@codecov

codecov Bot commented Aug 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.71975% with 13 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/api/providers/vscode-lm.ts 92.00% 2 Missing and 10 partials ⚠️
src/api/transform/vscode-lm-format.ts 85.71% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@github-actions github-actions Bot added the awaiting-review PR changes are ready and waiting for maintainer re-review label Aug 7, 2026
…ation 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).

@edelauna edelauna left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for your contirbution

Comment on lines +781 to +791
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 }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can a native LanguageModelToolCallPart (yielded at :761) arrive while salvageBuffering is true? If so, this flush emits leftoverText after that native tool_use, and cleanConversationHistory serializes in order — leaving text content following a tool_use block, which Anthropic rejects. Worth flushing the buffer as text before yielding a native call, or dropping the leftover-text emission for a buffer that spans one.

Comment on lines +114 to +118
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]) })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this recover a call when the model merely quotes the <invoke> markup (e.g. echoing a file snippet, or a "Do NOT run <invoke>…" negative example)? The only gate here is the tool name; prose that reproduces the markup is replayed as a real call with whatever arguments accompany it. Should the block need to be self-delimited, or wrapped in <antml:function_calls>, to count as an invocation?

if (before) {
yield { type: "text", text: before }
}
salvageBuffering = true

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once this flips true it stays true until stream end — a <invoke/<function_calls match in ordinary prose latches buffering for the entire remaining response, so real-time streaming stops and the tail is emitted as one chunk. Should this reset if the buffer never progresses toward a complete block (or latch only after name="…" is seen)?

Comment on lines +279 to +319
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 () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These recovery tests filter chunks by type and assert each independently, so the emission order (prose before the recovered tool_call) is never asserted — a swap would still pass. Also, no case mixes a native LanguageModelToolCallPart with leaked <invoke> text, which is the one interleaving that can yield an invalid tool_use-then-text message. Worth asserting the full chunk sequence and adding a native+leaked fixture?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

awaiting-review PR changes are ready and waiting for maintainer re-review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants