diff --git a/scripts/stryker-diff.mjs b/scripts/stryker-diff.mjs index 2d6adddfa2..2490312268 100644 --- a/scripts/stryker-diff.mjs +++ b/scripts/stryker-diff.mjs @@ -254,9 +254,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 931840606f..25f49e794d 100644 --- a/scripts/stryker-diff.test.mjs +++ b/scripts/stryker-diff.test.mjs @@ -56,6 +56,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( @@ -249,7 +329,6 @@ describe("shouldUseVitestRelated", () => { }) }) - describe("related-test discovery", () => { it("keeps Stryker's temp directory relative to each run root", () => { assert.equal(resolveStrykerTempDir("/repo", "/repo"), ".stryker-tmp") diff --git a/src/api/providers/__tests__/vscode-lm.spec.ts b/src/api/providers/__tests__/vscode-lm.spec.ts index 423f119f14..0dc770a389 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,19 @@ vi.mock("vscode", () => { }) import * as vscode from "vscode" -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" import { openAiModelInfoSaneDefaults, vscodeLlmDefaultModelId, vscodeLlmModels } from "@roo-code/types" import { clearAllMocks } from "../../../test-utils/reset" +import { collectStream } from "../../../test-utils/stream" const mockLanguageModelChat = { id: "test-model", @@ -272,6 +288,407 @@ describe("VsCodeLmHandler", () => { }) }) + it("still trims oversized tool_results when the system prompt consumes most of the budget", async () => { + // A system prompt large enough to drive the raw budget negative; the clamp keeps trimming + // active for the case where the request is most oversized. + const systemPrompt = "S".repeat(handler.getCondenseContextWindow() * 3) + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [{ type: "text", text: "hi" }], + }, + { + role: "assistant", + content: [{ type: "tool_use", id: "t1", name: "some_tool", input: { a: 1 } }], + }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "t1", content: "X".repeat(50_000) }], + }, + ] + + // No sendRequest response is queued: the request must be refused before it is sent, and a + // queued-but-unconsumed response would leak into later tests. + // The clamped floor cannot be met once the tool_result bottoms out at its minimum, so the + // request must be refused rather than sent over-window (which orphans the tool_result). + const stream = handler.createMessage(systemPrompt, messages, { taskId: "test-task" }) + await expect( + (async () => { + for await (const _chunk of stream) { + // drain + } + })(), + ).rejects.toThrow(/too large for this model's context window/) + expect(mockLanguageModelChat.sendRequest).not.toHaveBeenCalled() + }) + + it("refuses a request that exceeds a small positive raw budget below the trimming floor", async () => { + // The clamp to MIN_TOOL_RESULT_CHARS only keeps trimming productive; admission must still + // respect the raw budget, otherwise a conversation between the raw budget and the floor is + // sent over-window. Sized so the remaining content exceeds the raw budget but stays under + // the floor, and so no tool_result is large enough for trimming to shrink anything. + const targetRawBudgetChars = 1000 + const systemPrompt = "S".repeat( + Math.floor(handler.getCondenseContextWindow() * 0.8 * 3) - targetRawBudgetChars, + ) + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [{ type: "tool_use", id: "t1", name: "some_tool", input: { a: 1 } }], + }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "t1", content: "X".repeat(1500) }], + }, + ] + + // No sendRequest response is queued: refusal must happen before the request is sent. + const stream = handler.createMessage(systemPrompt, messages, { taskId: "test-task" }) + await expect( + (async () => { + for await (const _chunk of stream) { + // drain + } + })(), + ).rejects.toThrow(/too large for this model's context window/) + expect(mockLanguageModelChat.sendRequest).not.toHaveBeenCalled() + }) + + it("sends a request that fits within a small positive raw budget", async () => { + const targetRawBudgetChars = 1000 + const systemPrompt = "S".repeat( + Math.floor(handler.getCondenseContextWindow() * 0.8 * 3) - targetRawBudgetChars, + ) + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [{ type: "text", text: "Y".repeat(500) }], + }, + ] + + mockLanguageModelChat.sendRequest.mockResolvedValueOnce({ + stream: (async function* () { + yield new vscode.LanguageModelTextPart("ok") + return + })(), + text: (async function* () { + yield "ok" + return + })(), + }) + + const stream = handler.createMessage(systemPrompt, messages, { taskId: "test-task" }) + const chunks = await collectStream(stream) + + expect(mockLanguageModelChat.sendRequest).toHaveBeenCalled() + expect(chunks).toContainEqual({ type: "text", text: "ok" }) + }) + + it("sends the request when trimming brings the conversation back under budget", async () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [{ type: "tool_use", id: "t1", name: "some_tool", input: { a: 1 } }], + }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "t1", content: "X".repeat(500_000) }], + }, + ] + + mockLanguageModelChat.sendRequest.mockResolvedValueOnce({ + stream: (async function* () { + yield new vscode.LanguageModelTextPart("ok") + return + })(), + text: (async function* () { + yield "ok" + return + })(), + }) + + const stream = handler.createMessage("system", messages, { taskId: "test-task" }) + for await (const _chunk of stream) { + // drain + } + + const sent = JSON.stringify(mockLanguageModelChat.sendRequest.mock.calls[0][0]) + expect(sent).toContain("characters truncated") + expect(sent).not.toContain("X".repeat(400_000)) + }) + + 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 streamMixedParts = (parts: Array) => { + mockLanguageModelChat.sendRequest.mockResolvedValueOnce({ + stream: (async function* () { + for (const part of parts) { + yield typeof part === "string" + ? new vscode.LanguageModelTextPart(part) + : new vscode.LanguageModelToolCallPart("native-1", part.name, part.input) + } + return + })(), + text: (async function* () { + yield "" + return + })(), + }) + } + + const drain = async () => { + 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 + } + + const collect = async (parts: string[]) => { + streamTextParts(parts) + return drain() + } + + it("recovers a tool call the model streamed as raw invoke XML", async () => { + const chunks = await collect([ + "Thinking. ", + 'add', + ]) + + expect(chunks.filter((chunk) => chunk.type === "text")).toEqual([{ type: "text", text: "Thinking. " }]) + expect(chunks.filter((chunk) => chunk.type === "tool_call")).toEqual([ + { + type: "tool_call", + id: expect.stringContaining("vscodelm-salvaged-"), + name: "calculator", + arguments: JSON.stringify({ operation: "add" }), + }, + ]) + }) + + it("detects a marker split across stream chunks", async () => { + const chunks = await collect([ + "abc sub', + ]) + + expect(chunks.filter((chunk) => chunk.type === "text")).toEqual([{ type: "text", text: "abc " }]) + expect(chunks.filter((chunk) => chunk.type === "tool_call")).toMatchObject([ + { name: "calculator", arguments: JSON.stringify({ operation: "sub" }) }, + ]) + }) + + it("emits a carried tail as plain text when it never becomes a marker", async () => { + const chunks = await collect(["hello chunk.type === "text")).toEqual([ + { type: "text", text: "hello " }, + { type: "text", text: " chunk.type === "tool_call")).toBe(false) + }) + + it("buffers across chunks that arrive after the marker", async () => { + const chunks = await collect([ + 'prose ', + '', + "mul", + "", + ]) + + expect(chunks.filter((chunk) => chunk.type === "text")).toEqual([{ type: "text", text: "prose " }]) + expect(chunks.filter((chunk) => chunk.type === "tool_call")).toMatchObject([ + { name: "calculator", arguments: JSON.stringify({ operation: "mul" }) }, + ]) + }) + + it("recovers a null-only declared parameter as JSON null through createMessage", async () => { + mockLanguageModelChat.sendRequest.mockResolvedValueOnce({ + stream: (async function* () { + yield new vscode.LanguageModelTextPart( + 'null', + ) + return + })(), + text: (async function* () { + yield "" + return + })(), + }) + + const stream = handler.createMessage("system", [{ role: "user" as const, content: "hi" }], { + taskId: "test-task", + tools: [ + { + type: "function" as const, + function: { + name: "nuller", + description: "", + parameters: { type: "object", properties: { cursor: { type: "null" } } }, + }, + }, + ], + }) + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + expect(chunks.filter((chunk) => chunk.type === "tool_call")).toMatchObject([ + { name: "nuller", arguments: JSON.stringify({ cursor: null }) }, + ]) + }) + + it("keeps an invoke block for an unknown tool as literal text", async () => { + const block = '1' + const chunks = await collect([block]) + + expect(chunks.filter((chunk) => chunk.type === "text")).toEqual([{ type: "text", text: block }]) + expect(chunks.some((chunk) => chunk.type === "tool_call")).toBe(false) + }) + + it("emits prose before the recovered tool call", async () => { + const chunks = await collect([ + "Thinking. ", + 'add', + ]) + + expect(chunks.map((chunk) => chunk.type)).toEqual(["text", "tool_call", "usage"]) + }) + + it("flushes buffered text before a native tool call so no text follows a tool_use", async () => { + streamMixedParts([ + 'partial ', + { name: "calculator", input: { operation: "div" } }, + ]) + const chunks = await drain() + + // The ordering comparison is only meaningful once both kinds of chunk exist: a + // silently broken flush emits no text at all, and -1 < firstToolCall would still hold. + expect(chunks.filter((chunk) => chunk.type === "text")).toEqual([ + { type: "text", text: "partial " }, + { type: "text", text: '' }, + ]) + const types = chunks.map((chunk) => chunk.type) + const lastText = types.lastIndexOf("text") + const firstToolCall = types.indexOf("tool_call") + expect(lastText).toBeGreaterThanOrEqual(0) + expect(firstToolCall).toBeGreaterThanOrEqual(0) + expect(firstToolCall).toBeGreaterThan(lastText) + }) + + it("does not latch buffering on prose that merely mentions the tag", async () => { + const chunks = await collect(["never emit markup as text. ", "Streaming continues."]) + + expect(chunks.filter((chunk) => chunk.type === "text")).toEqual([ + { type: "text", text: "never emit markup as text. " }, + { type: "text", text: "Streaming continues." }, + ]) + expect(chunks.some((chunk) => chunk.type === "tool_call")).toBe(false) + }) + + it("does not recover an invoke block quoted inside a fenced code block", async () => { + const block = 'add' + const chunks = await collect(["Do NOT do this:\n```\n" + block + "\n```\n"]) + + expect(chunks.some((chunk) => chunk.type === "tool_call")).toBe(false) + }) + + it("flushes an over-long never-closing invoke as plain text before the stream ends", async () => { + // Defect 4: without a cap the buffer is only drained once the stream finishes, so the + // user sees nothing until then. Releasing it at the end looks identical in content — + // only the timing distinguishes the fix, so track how much of the source has been + // produced at the moment each text chunk reaches the consumer. + const filler = "x".repeat(5000) + const parts = ['', filler, filler, filler, filler] + let partsProduced = 0 + + mockLanguageModelChat.sendRequest.mockResolvedValueOnce({ + stream: (async function* () { + for (const part of parts) { + partsProduced++ + yield new vscode.LanguageModelTextPart(part) + } + return + })(), + text: (async function* () { + yield "" + return + })(), + }) + + const stream = handler.createMessage("system", [{ role: "user" as const, content: "hi" }], { + taskId: "test-task", + tools: salvageTools, + }) + + let sawTextBeforeStreamEnd = false + let streamedText = "" + for await (const chunk of stream) { + if (chunk.type === "text") { + streamedText += chunk.text + if (partsProduced < parts.length) { + sawTextBeforeStreamEnd = true + } + } + } + + expect(sawTextBeforeStreamEnd).toBe(true) + expect(streamedText).toContain('') + expect(streamedText).toContain(filler) + }) + }) + + describe("system prompt sanitization", () => { + it("sanitizes lone surrogates in the system prompt", async () => { + mockLanguageModelChat.sendRequest.mockResolvedValueOnce({ + stream: (async function* () { + yield new vscode.LanguageModelTextPart("ok") + return + })(), + text: (async function* () { + yield "ok" + return + })(), + }) + const stream = handler.createMessage("sys\uD800tem", [{ role: "user" as const, content: "hi" }]) + for await (const _chunk of stream) { + // drain + } + + expect(vscode.LanguageModelChatMessage.Assistant).toHaveBeenCalledWith("sys\uFFFDtem") + }) + }) + it("should handle native tool calls when tools are provided", async () => { const systemPrompt = "You are a helpful assistant" const messages: Anthropic.Messages.MessageParam[] = [ @@ -1077,3 +1494,667 @@ 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")) + "<