fix(vscode-lm): add tool call/result sequence validation

Fixes #10843

The VSCode Language Model API requires that when an Assistant message
contains LanguageModelToolCallPart(s), the immediately next message MUST
be a User message with LanguageModelToolResultPart(s) with matching callIds.

This commit adds a validateAndRepairToolSequence function that:
- Identifies assistant messages with tool calls
- Checks that the next message is a user message with matching tool results
- Adds placeholder ToolResultParts for any missing tool results
- Creates user messages with placeholder results when no user message follows

This fixes the 'Invalid request: Tool call part must be followed by a User
message with a LanguageModelToolResultPart with a matching callId' error
that occurs when using MCP servers (like JIRA/Confluence) with the vscode-lm
provider in versions after 3.36.
This commit is contained in:
Roo Code 2026-01-21 09:12:24 +00:00
parent 9ab279ae42
commit 40d6b5e3c1
3 changed files with 323 additions and 3 deletions

View file

@ -9,7 +9,11 @@ import { SELECTOR_SEPARATOR, stringifyVsCodeLmModelSelector } from "../../shared
import { normalizeToolSchema } from "../../utils/json-schema"
import { ApiStream } from "../transform/stream"
import { convertToVsCodeLmMessages, extractTextCountFromMessage } from "../transform/vscode-lm-format"
import {
convertToVsCodeLmMessages,
extractTextCountFromMessage,
validateAndRepairToolSequence,
} from "../transform/vscode-lm-format"
import { BaseProvider } from "./base-provider"
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
@ -367,9 +371,16 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan
}))
// Convert Anthropic messages to VS Code LM messages
const convertedMessages = convertToVsCodeLmMessages(cleanedMessages)
// Validate and repair tool call/result sequences to ensure VSCode LM API requirements are met
// The API requires that assistant messages with ToolCallParts are immediately followed by
// user messages with matching ToolResultParts
const validatedMessages = validateAndRepairToolSequence(convertedMessages)
const vsCodeLmMessages: vscode.LanguageModelChatMessage[] = [
vscode.LanguageModelChatMessage.Assistant(systemPrompt),
...convertToVsCodeLmMessages(cleanedMessages),
...validatedMessages,
]
// Initialize cancellation token for the request

View file

@ -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,
validateAndRepairToolSequence,
} from "../vscode-lm-format"
// Mock crypto using Vitest
vitest.stubGlobal("crypto", {
@ -346,3 +351,167 @@ describe("extractTextCountFromMessage", () => {
expect(result).toBe("result-id")
})
})
describe("validateAndRepairToolSequence", () => {
it("should return empty array for empty input", () => {
const result = validateAndRepairToolSequence([])
expect(result).toEqual([])
})
it("should return messages unchanged when no tool calls present", () => {
const mockTextPart = new (vitest.mocked(vscode).LanguageModelTextPart)("Hello")
const messages = [
{
role: vitest.mocked(vscode).LanguageModelChatMessageRole.User,
content: [mockTextPart],
},
{
role: vitest.mocked(vscode).LanguageModelChatMessageRole.Assistant,
content: [mockTextPart],
},
] as vscode.LanguageModelChatMessage[]
const result = validateAndRepairToolSequence(messages)
expect(result).toHaveLength(2)
expect(result[0]).toBe(messages[0])
expect(result[1]).toBe(messages[1])
})
it("should return messages unchanged when tool call is followed by matching tool result", () => {
const mockTextPart = new (vitest.mocked(vscode).LanguageModelTextPart)("I'll help")
const mockToolCallPart = new (vitest.mocked(vscode).LanguageModelToolCallPart)("call-1", "test-tool", {})
const mockToolResultPart = new (vitest.mocked(vscode).LanguageModelToolResultPart)("call-1", [
new (vitest.mocked(vscode).LanguageModelTextPart)("result"),
])
const assistantMessage = {
role: vitest.mocked(vscode).LanguageModelChatMessageRole.Assistant,
content: [mockTextPart, mockToolCallPart],
} as vscode.LanguageModelChatMessage
const userMessage = {
role: vitest.mocked(vscode).LanguageModelChatMessageRole.User,
content: [mockToolResultPart],
} as vscode.LanguageModelChatMessage
const messages = [assistantMessage, userMessage]
const result = validateAndRepairToolSequence(messages)
expect(result).toHaveLength(2)
expect(result[0]).toBe(assistantMessage)
expect(result[1]).toBe(userMessage)
})
it("should add placeholder tool result when assistant tool call is not followed by user message", () => {
const mockTextPart = new (vitest.mocked(vscode).LanguageModelTextPart)("I'll help")
const mockToolCallPart = new (vitest.mocked(vscode).LanguageModelToolCallPart)("call-1", "test-tool", {})
const assistantMessage = {
role: vitest.mocked(vscode).LanguageModelChatMessageRole.Assistant,
content: [mockTextPart, mockToolCallPart],
} as vscode.LanguageModelChatMessage
const messages = [assistantMessage]
const result = validateAndRepairToolSequence(messages)
// Should have original assistant message plus new user message with placeholder
expect(result).toHaveLength(2)
expect(result[0]).toBe(assistantMessage)
expect(result[1].role).toBe(vitest.mocked(vscode).LanguageModelChatMessageRole.User)
// The new user message should have a tool result part
expect(result[1].content).toHaveLength(1)
expect((result[1].content[0] as MockLanguageModelToolResultPart).callId).toBe("call-1")
})
it("should add placeholder tool result when tool call is followed by user message without matching result", () => {
const mockTextPart = new (vitest.mocked(vscode).LanguageModelTextPart)("I'll help")
const mockToolCallPart = new (vitest.mocked(vscode).LanguageModelToolCallPart)("call-1", "test-tool", {})
const mockUserTextPart = new (vitest.mocked(vscode).LanguageModelTextPart)("Continue please")
const assistantMessage = {
role: vitest.mocked(vscode).LanguageModelChatMessageRole.Assistant,
content: [mockTextPart, mockToolCallPart],
} as vscode.LanguageModelChatMessage
const userMessage = {
role: vitest.mocked(vscode).LanguageModelChatMessageRole.User,
content: [mockUserTextPart],
} as vscode.LanguageModelChatMessage
const messages = [assistantMessage, userMessage]
const result = validateAndRepairToolSequence(messages)
expect(result).toHaveLength(2)
expect(result[0]).toBe(assistantMessage)
// The repaired user message should have placeholder result prepended
expect(result[1].content).toHaveLength(2)
expect((result[1].content[0] as MockLanguageModelToolResultPart).callId).toBe("call-1")
expect((result[1].content[1] as MockLanguageModelTextPart).value).toBe("Continue please")
})
it("should handle multiple tool calls with partial matching results", () => {
const mockTextPart = new (vitest.mocked(vscode).LanguageModelTextPart)("I'll help")
const mockToolCallPart1 = new (vitest.mocked(vscode).LanguageModelToolCallPart)("call-1", "tool-1", {})
const mockToolCallPart2 = new (vitest.mocked(vscode).LanguageModelToolCallPart)("call-2", "tool-2", {})
const mockToolResultPart1 = new (vitest.mocked(vscode).LanguageModelToolResultPart)("call-1", [
new (vitest.mocked(vscode).LanguageModelTextPart)("result-1"),
])
const assistantMessage = {
role: vitest.mocked(vscode).LanguageModelChatMessageRole.Assistant,
content: [mockTextPart, mockToolCallPart1, mockToolCallPart2],
} as vscode.LanguageModelChatMessage
const userMessage = {
role: vitest.mocked(vscode).LanguageModelChatMessageRole.User,
content: [mockToolResultPart1], // Only has result for call-1, missing call-2
} as vscode.LanguageModelChatMessage
const messages = [assistantMessage, userMessage]
const result = validateAndRepairToolSequence(messages)
expect(result).toHaveLength(2)
expect(result[0]).toBe(assistantMessage)
// The repaired user message should have placeholder for call-2 prepended
expect(result[1].content).toHaveLength(2)
// Placeholder for missing call-2 should be first
expect((result[1].content[0] as MockLanguageModelToolResultPart).callId).toBe("call-2")
// Original result for call-1 should still be present
expect((result[1].content[1] as MockLanguageModelToolResultPart).callId).toBe("call-1")
})
it("should handle tool call followed by another assistant message", () => {
const mockTextPart = new (vitest.mocked(vscode).LanguageModelTextPart)("I'll help")
const mockToolCallPart = new (vitest.mocked(vscode).LanguageModelToolCallPart)("call-1", "test-tool", {})
const mockTextPart2 = new (vitest.mocked(vscode).LanguageModelTextPart)("More assistant text")
const assistantMessage1 = {
role: vitest.mocked(vscode).LanguageModelChatMessageRole.Assistant,
content: [mockTextPart, mockToolCallPart],
} as vscode.LanguageModelChatMessage
const assistantMessage2 = {
role: vitest.mocked(vscode).LanguageModelChatMessageRole.Assistant,
content: [mockTextPart2],
} as vscode.LanguageModelChatMessage
const messages = [assistantMessage1, assistantMessage2]
const result = validateAndRepairToolSequence(messages)
// Should insert a user message with placeholder between the two assistant messages
expect(result).toHaveLength(3)
expect(result[0]).toBe(assistantMessage1)
expect(result[1].role).toBe(vitest.mocked(vscode).LanguageModelChatMessageRole.User)
expect((result[1].content[0] as MockLanguageModelToolResultPart).callId).toBe("call-1")
expect(result[2]).toBe(assistantMessage2)
})
it("should handle null/undefined input gracefully", () => {
expect(validateAndRepairToolSequence(null as any)).toEqual(null)
expect(validateAndRepairToolSequence(undefined as any)).toEqual(undefined)
})
})

View file

@ -194,3 +194,143 @@ export function extractTextCountFromMessage(message: vscode.LanguageModelChatMes
}
return text
}
/**
* Helper function to extract tool call IDs from a message's content.
* Returns an array of call IDs from LanguageModelToolCallPart instances.
*/
function getToolCallIds(message: vscode.LanguageModelChatMessage): string[] {
const callIds: string[] = []
if (Array.isArray(message.content)) {
for (const part of message.content) {
if (part instanceof vscode.LanguageModelToolCallPart) {
callIds.push(part.callId)
}
}
}
return callIds
}
/**
* Helper function to extract tool result IDs from a message's content.
* Returns an array of call IDs from LanguageModelToolResultPart instances.
*/
function getToolResultIds(message: vscode.LanguageModelChatMessage): string[] {
const callIds: string[] = []
if (Array.isArray(message.content)) {
for (const part of message.content) {
if (part instanceof vscode.LanguageModelToolResultPart) {
callIds.push(part.callId)
}
}
}
return callIds
}
/**
* Validates and repairs the tool call/result sequence in VSCode LM messages.
*
* The VSCode Language Model API requires that:
* - When an Assistant message contains LanguageModelToolCallPart(s)
* - The immediately next message MUST be a User message with LanguageModelToolResultPart(s)
* with matching callIds
*
* This function:
* 1. Identifies assistant messages with tool calls
* 2. Checks that the next message is a user message with matching tool results
* 3. If mismatches are found:
* - Missing tool results: Adds placeholder ToolResultParts to the user message
* - Orphaned tool results (no preceding tool call): Removes them
* - No following user message: Creates one with the necessary tool results
*
* @param messages - Array of VSCode LanguageModelChatMessage to validate and repair
* @returns The validated and repaired array of messages
*/
export function validateAndRepairToolSequence(
messages: vscode.LanguageModelChatMessage[],
): vscode.LanguageModelChatMessage[] {
if (!messages || messages.length === 0) {
return messages
}
const repairedMessages: vscode.LanguageModelChatMessage[] = []
for (let i = 0; i < messages.length; i++) {
const currentMessage = messages[i]
const isAssistant = currentMessage.role === vscode.LanguageModelChatMessageRole.Assistant
const toolCallIds = getToolCallIds(currentMessage)
// If this is an assistant message with tool calls
if (isAssistant && toolCallIds.length > 0) {
repairedMessages.push(currentMessage)
const nextMessage = messages[i + 1]
const isNextUser = nextMessage?.role === vscode.LanguageModelChatMessageRole.User
if (isNextUser) {
// Check if the next user message has matching tool results
const toolResultIds = getToolResultIds(nextMessage)
const missingResultIds = toolCallIds.filter((id) => !toolResultIds.includes(id))
if (missingResultIds.length > 0) {
// Need to add placeholder tool results for missing IDs
// Filter existing content to only include valid user message parts (TextPart and ToolResultPart)
const existingContent: (vscode.LanguageModelTextPart | vscode.LanguageModelToolResultPart)[] = []
if (Array.isArray(nextMessage.content)) {
for (const part of nextMessage.content) {
if (
part instanceof vscode.LanguageModelTextPart ||
part instanceof vscode.LanguageModelToolResultPart
) {
existingContent.push(part)
}
}
}
// Add placeholder results at the beginning (tool results come first)
const placeholderResults = missingResultIds.map(
(callId) =>
new vscode.LanguageModelToolResultPart(callId, [
new vscode.LanguageModelTextPart("[Tool result not available]"),
]),
)
// Create new user message with placeholders prepended
const repairedUserMessage = vscode.LanguageModelChatMessage.User([
...placeholderResults,
...existingContent,
])
repairedMessages.push(repairedUserMessage)
i++ // Skip the next message since we've processed it
console.warn(
`Roo Code <Language Model API>: Added ${missingResultIds.length} placeholder tool result(s) for missing call IDs: ${missingResultIds.join(", ")}`,
)
} else {
// All tool results are present, keep the message as-is
repairedMessages.push(nextMessage)
i++ // Skip the next message since we've processed it
}
} else {
// No user message follows - create one with placeholder tool results
const placeholderResults = toolCallIds.map(
(callId) =>
new vscode.LanguageModelToolResultPart(callId, [
new vscode.LanguageModelTextPart("[Tool result not available]"),
]),
)
const newUserMessage = vscode.LanguageModelChatMessage.User(placeholderResults)
repairedMessages.push(newUserMessage)
console.warn(
`Roo Code <Language Model API>: Created user message with ${toolCallIds.length} placeholder tool result(s) for call IDs: ${toolCallIds.join(", ")}`,
)
}
} else {
// For non-tool-call messages, just add them directly
repairedMessages.push(currentMessage)
}
}
return repairedMessages
}