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")) + "<