diff --git a/scripts/stryker-diff.mjs b/scripts/stryker-diff.mjs
index a69d272d75..c1d6b46f4f 100644
--- a/scripts/stryker-diff.mjs
+++ b/scripts/stryker-diff.mjs
@@ -259,9 +259,19 @@ function git(repoRoot, args) {
return execFileSync("git", args, { cwd: repoRoot, encoding: "utf8", maxBuffer: 20 * 1024 * 1024 })
}
+// GitHub checks out the synthetic pull request merge commit, but `pull_request.base.sha` is frozen at
+// event-creation time. When main advances afterwards, that stale base attributes unrelated upstream
+// lines to the pull request. The merge commit's first parent is the base actually merged into.
+export function resolvePullRequestBase(repoRoot, baseSha, headSha) {
+ const parents = git(repoRoot, ["rev-list", "--parents", "-n", "1", headSha]).trim().split(/\s+/).slice(1)
+ if (parents.length < 2) return baseSha
+ return parents[0]
+}
+
export function selectFromGit(repoRoot, baseSha, headSha) {
validateSha(baseSha, "base SHA")
validateSha(headSha, "head SHA")
+ baseSha = resolvePullRequestBase(repoRoot, baseSha, headSha)
const mergeBase = git(repoRoot, ["merge-base", baseSha, headSha]).trim()
const nameStatus = git(repoRoot, ["diff", "--name-status", "-z", "--find-renames", `${mergeBase}...${headSha}`])
const entries = parseNameStatus(nameStatus)
diff --git a/scripts/stryker-diff.test.mjs b/scripts/stryker-diff.test.mjs
index 535581ab17..389cb9b583 100644
--- a/scripts/stryker-diff.test.mjs
+++ b/scripts/stryker-diff.test.mjs
@@ -63,6 +63,86 @@ describe("mutation testing workflow", () => {
})
})
+function createSyntheticPullRequestRepository() {
+ const repository = fs.mkdtempSync(path.join(os.tmpdir(), "stryker-diff-revision-"))
+ const run = (...args) => execFileSync("git", args, { cwd: repository, encoding: "utf8" }).trim()
+ const write = (filePath, contents) => {
+ fs.mkdirSync(path.join(repository, path.dirname(filePath)), { recursive: true })
+ fs.writeFileSync(path.join(repository, filePath), contents)
+ }
+
+ run("init", "--quiet", "--initial-branch", "main")
+ run("config", "user.email", "gate@example.com")
+ run("config", "user.name", "Gate")
+ run("config", "commit.gpgsign", "false")
+
+ write("packages/core/src/unrelated.ts", "export const unrelated = () => 1\n")
+ write("packages/core/src/feature.ts", "export const feature = () => 1\n")
+ run("add", ".")
+ run("commit", "--quiet", "-m", "initial")
+ const eventBaseSha = run("rev-parse", "HEAD")
+
+ run("checkout", "--quiet", "-b", "pull-request")
+ write("packages/core/src/feature.ts", "export const feature = () => 2\n")
+ run("add", ".")
+ run("commit", "--quiet", "-m", "pull request change")
+
+ // The upstream change lands after the pull_request event recorded its base SHA, which is what
+ // made the stale event base attribute unrelated main-only lines to the pull request.
+ run("checkout", "--quiet", "main")
+ write("packages/core/src/unrelated.ts", "export const unrelated = () => 99\n")
+ run("add", ".")
+ run("commit", "--quiet", "-m", "unrelated upstream change")
+ const upstreamSha = run("rev-parse", "HEAD")
+
+ run("merge", "--quiet", "--no-ff", "-m", "merge pull request", "pull-request")
+ const mergeSha = run("rev-parse", "HEAD")
+
+ return { repository, eventBaseSha, upstreamSha, mergeSha }
+}
+
+describe("pull request revision selection", () => {
+ it("excludes unrelated upstream files by diffing from the merge commit's first parent", () => {
+ const { repository, eventBaseSha, upstreamSha, mergeSha } = createSyntheticPullRequestRepository()
+
+ // A failed assertion must still remove the temporary repository, or a failing run leaks it.
+ try {
+ const manifest = selectFromGit(repository, eventBaseSha, mergeSha)
+ const changedPaths = manifest.packages.flatMap((entry) => entry.files.map((file) => file.path))
+
+ assert.deepEqual(changedPaths, ["packages/core/src/feature.ts"])
+ assert.equal(manifest.baseSha, upstreamSha)
+ assert.equal(manifest.mergeBase, upstreamSha)
+
+ // Selectors must stay aligned with the checked-out head content.
+ assert.equal(manifest.headSha, mergeSha)
+ assert.deepEqual(
+ manifest.packages.flatMap((entry) => entry.selectors),
+ ["src/feature.ts:1-1"],
+ )
+ } finally {
+ fs.rmSync(repository, { recursive: true, force: true })
+ }
+ })
+
+ it("keeps the supplied base for non-merge heads such as manual runs", () => {
+ const { repository, eventBaseSha, upstreamSha } = createSyntheticPullRequestRepository()
+
+ try {
+ const manifest = selectFromGit(repository, eventBaseSha, upstreamSha)
+
+ assert.equal(manifest.baseSha, eventBaseSha)
+ assert.equal(manifest.mergeBase, eventBaseSha)
+ assert.deepEqual(
+ manifest.packages.flatMap((entry) => entry.files.map((file) => file.path)),
+ ["packages/core/src/unrelated.ts"],
+ )
+ } finally {
+ fs.rmSync(repository, { recursive: true, force: true })
+ }
+ })
+})
+
describe("parseNameStatus", () => {
it("parses added, modified, and renamed paths", () => {
assert.deepEqual(
diff --git a/src/api/providers/__tests__/vscode-lm.spec.ts b/src/api/providers/__tests__/vscode-lm.spec.ts
index 423f119f14..666c8e5ce4 100644
--- a/src/api/providers/__tests__/vscode-lm.spec.ts
+++ b/src/api/providers/__tests__/vscode-lm.spec.ts
@@ -16,6 +16,14 @@ vi.mock("vscode", () => {
) {}
}
+ class MockLanguageModelToolResultPart {
+ type = "tool_result"
+ constructor(
+ public callId: string,
+ public content: unknown[],
+ ) {}
+ }
+
return {
workspace: {
getConfiguration: vi.fn(() => ({
@@ -53,6 +61,7 @@ vi.mock("vscode", () => {
},
LanguageModelTextPart: MockLanguageModelTextPart,
LanguageModelToolCallPart: MockLanguageModelToolCallPart,
+ LanguageModelToolResultPart: MockLanguageModelToolResultPart,
lm: {
selectChatModels: vi.fn(),
},
@@ -60,12 +69,16 @@ vi.mock("vscode", () => {
})
import * as vscode from "vscode"
-import { VsCodeLmHandler } from "../vscode-lm"
+import { VsCodeLmHandler, extractLeakedToolCalls, trailingPartialToolMarkerLength } from "../vscode-lm"
import type { ApiHandlerOptions } from "../../../shared/api"
import type { Anthropic } from "@anthropic-ai/sdk"
import { openAiModelInfoSaneDefaults, vscodeLlmDefaultModelId, vscodeLlmModels } from "@roo-code/types"
+import { normalizeToolSchema } from "../../../utils/json-schema"
+import { getMcpServerTools } from "../../../core/prompts/tools/native-tools/mcp_server"
+import type { McpHub } from "../../../services/mcp/McpHub"
import { clearAllMocks } from "../../../test-utils/reset"
+import { collectStream } from "../../../test-utils/stream"
const mockLanguageModelChat = {
id: "test-model",
@@ -1077,3 +1090,807 @@ 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}`
+ const wrap = (body: string) => `${body}`
+
+ describe("extractLeakedToolCalls", () => {
+ it("recovers a known-tool block and strips it from the leftover text", () => {
+ const text = `Working on it.\n${wrap(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 a wrapped leak preceded by a stray token", () => {
+ const text = `court\n${wrap(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("does not recover a bare invoke block with no function_calls wrapper", () => {
+ const text = `court\n${invoke("update_todo_list", param("todos", "[x] done"))}`
+
+ const { calls, leftoverText } = extractLeakedToolCalls(text, new Set(["update_todo_list"]))
+
+ expect(calls).toHaveLength(0)
+ expect(leftoverText).toBe(text)
+ })
+
+ it("does not recover an invoke that follows an already-closed wrapper", () => {
+ const text = `${wrap("")}\n${invoke("update_todo_list", param("todos", "[x] done"))}`
+
+ const { calls } = extractLeakedToolCalls(text, new Set(["update_todo_list"]))
+
+ expect(calls).toHaveLength(0)
+ })
+
+ 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)
+ })
+
+ it("holds back an invoke tag whose name attribute has not arrived", () => {
+ expect(trailingPartialToolMarkerLength("text {
+ expect(trailingPartialToolMarkerLength(" {
+ expect(trailingPartialToolMarkerLength("text <" + "a".repeat(200))).toBe(0)
+ })
+ })
+
+ describe("quoted markup", () => {
+ it("does not recover an invoke block inside a fenced code block", () => {
+ const text = "```\n" + invoke("update_todo_list", param("todos", "[x] one")) + "\n```"
+
+ const { calls, leftoverText } = extractLeakedToolCalls(text, new Set(["update_todo_list"]))
+
+ expect(calls).toHaveLength(0)
+ expect(leftoverText).toBe(text)
+ })
+
+ it("does not recover an invoke block inside an inline code span", () => {
+ const text = "avoid `" + invoke("update_todo_list", param("todos", "x")) + "`"
+
+ const { calls } = extractLeakedToolCalls(text, new Set(["update_todo_list"]))
+
+ expect(calls).toHaveLength(0)
+ })
+
+ it("does not recover an invoke block quoted in unfenced, backtick-free prose", () => {
+ const text = "You must never emit " + invoke("update_todo_list", param("todos", "x")) + " directly."
+
+ const { calls, leftoverText } = extractLeakedToolCalls(text, new Set(["update_todo_list"]))
+
+ expect(calls).toHaveLength(0)
+ expect(leftoverText).toBe(text)
+ })
+
+ it("does not recover a quoted invoke block that ends its line", () => {
+ // Defect 3: an empty rest-of-line previously made this look like a genuine leak.
+ const text = "You must never emit " + invoke("update_todo_list", param("todos", "x"))
+
+ const { calls, leftoverText } = extractLeakedToolCalls(text, new Set(["update_todo_list"]))
+
+ expect(calls).toHaveLength(0)
+ expect(leftoverText).toBe(text)
+ })
+
+ it("does not recover an invoke block inside a tilde fence", () => {
+ const text = "~~~\n" + invoke("update_todo_list", param("todos", "[x] one")) + "\n~~~"
+
+ const { calls, leftoverText } = extractLeakedToolCalls(text, new Set(["update_todo_list"]))
+
+ expect(calls).toHaveLength(0)
+ expect(leftoverText).toBe(text)
+ })
+
+ it("does not recover an invoke inside a four-backtick fence containing a three-backtick fence", () => {
+ // A narrower inner fence must not close the wider outer one, so the invoke stays quoted.
+ const text = "````\n```\n" + invoke("update_todo_list", param("todos", "[x] one")) + "\n```\n````"
+
+ const { calls, leftoverText } = extractLeakedToolCalls(text, new Set(["update_todo_list"]))
+
+ expect(calls).toHaveLength(0)
+ expect(leftoverText).toBe(text)
+ })
+
+ it("does not recover an invoke inside a tilde fence containing a backtick fence line", () => {
+ const text = "~~~\n```\n" + invoke("update_todo_list", param("todos", "[x] one")) + "\n```\n~~~"
+
+ const { calls, leftoverText } = extractLeakedToolCalls(text, new Set(["update_todo_list"]))
+
+ expect(calls).toHaveLength(0)
+ expect(leftoverText).toBe(text)
+ })
+
+ it("recovers an invoke block that follows a closed code fence", () => {
+ const text = "```\nexample output\n```\n" + wrap(invoke("update_todo_list", param("todos", "[x] one")))
+
+ const { calls } = extractLeakedToolCalls(text, new Set(["update_todo_list"]))
+
+ expect(calls).toEqual([{ name: "update_todo_list", input: { todos: "[x] one" } }])
+ })
+
+ it("does not treat doubled angle brackets as trailing prose after stripping", () => {
+ // Defect 1: a single strip pass turns `<>` into a tag-looking ``, so the
+ // trailing-text check must strip repeatedly until stable.
+ const text = wrap(invoke("update_todo_list", param("todos", "x")) + "<