feat: Allow escaping of context mentions (#4362)

* allow escaping of context mentions

* refactor: update comment

---------

Co-authored-by: cannuri <91494156+cannuri@users.noreply.github.com>
Co-authored-by: Daniel <57051444+daniel-lxs@users.noreply.github.com>
This commit is contained in:
KJ7LNW 2025-06-12 08:49:19 -07:00 committed by GitHub
parent 8f58737550
commit 53e7e6beaa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 200 additions and 4 deletions

View file

@ -0,0 +1,186 @@
import { jest } from "@jest/globals"
import type { AskApproval, HandleError } from "../../../shared/tools" // Import the types
// Mock dependencies before importing the module under test
// Explicitly type the mock functions
const mockAskApproval = jest.fn<AskApproval>()
const mockHandleError = jest.fn<HandleError>() // Explicitly type HandleError
const mockPushToolResult = jest.fn()
const mockRemoveClosingTag = jest.fn((_name: string, value: string | undefined) => value ?? "") // Simple mock
const mockGetModeBySlug = jest.fn()
// Define a minimal type for the resolved value
type MockClineInstance = { taskId: string }
// Make initClineWithTask return a mock Cline-like object with taskId, providing type hint
const mockInitClineWithTask = jest
.fn<() => Promise<MockClineInstance>>()
.mockResolvedValue({ taskId: "mock-subtask-id" })
const mockEmit = jest.fn()
const mockRecordToolError = jest.fn()
const mockSayAndCreateMissingParamError = jest.fn()
// Mock the Cline instance and its methods/properties
const mockCline = {
ask: jest.fn(),
sayAndCreateMissingParamError: mockSayAndCreateMissingParamError,
emit: mockEmit,
recordToolError: mockRecordToolError,
consecutiveMistakeCount: 0,
isPaused: false,
pausedModeSlug: "ask", // Default or mock value
providerRef: {
deref: jest.fn(() => ({
getState: jest.fn(() => ({ customModes: [], mode: "ask" })), // Mock provider state
handleModeSwitch: jest.fn(),
initClineWithTask: mockInitClineWithTask,
})),
},
}
// Mock other modules
jest.mock("delay", () => jest.fn(() => Promise.resolve())) // Mock delay to resolve immediately
jest.mock("../../../shared/modes", () => ({
// Corrected path
getModeBySlug: mockGetModeBySlug,
defaultModeSlug: "ask",
}))
jest.mock("../../prompts/responses", () => ({
// Corrected path
formatResponse: {
toolError: jest.fn((msg: string) => `Tool Error: ${msg}`), // Simple mock
},
}))
// Import the function to test AFTER mocks are set up
import { newTaskTool } from "../newTaskTool"
import type { ToolUse } from "../../../shared/tools"
describe("newTaskTool", () => {
beforeEach(() => {
// Reset mocks before each test
jest.clearAllMocks()
mockAskApproval.mockResolvedValue(true) // Default to approved
mockGetModeBySlug.mockReturnValue({ slug: "code", name: "Code Mode" }) // Default valid mode
mockCline.consecutiveMistakeCount = 0
mockCline.isPaused = false
})
it("should correctly un-escape \\\\@ to \\@ in the message passed to the new task", async () => {
const block: ToolUse = {
type: "tool_use", // Add required 'type' property
name: "new_task", // Correct property name
params: {
mode: "code",
message: "Review this: \\\\@file1.txt and also \\\\\\\\@file2.txt", // Input with \\@ and \\\\@
},
partial: false,
}
await newTaskTool(
mockCline as any, // Use 'as any' for simplicity in mocking complex type
block,
mockAskApproval, // Now correctly typed
mockHandleError,
mockPushToolResult,
mockRemoveClosingTag,
)
// Verify askApproval was called
expect(mockAskApproval).toHaveBeenCalled()
// Verify the message passed to initClineWithTask reflects the code's behavior in unit tests
expect(mockInitClineWithTask).toHaveBeenCalledWith(
"Review this: \\@file1.txt and also \\\\\\@file2.txt", // Unit Test Expectation: \\@ -> \@, \\\\@ -> \\\\@
undefined,
mockCline,
)
// Verify side effects
expect(mockCline.emit).toHaveBeenCalledWith("taskSpawned", expect.any(String)) // Assuming initCline returns a mock task ID
expect(mockCline.isPaused).toBe(true)
expect(mockCline.emit).toHaveBeenCalledWith("taskPaused")
expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Successfully created new task"))
})
it("should not un-escape single escaped \@", async () => {
const block: ToolUse = {
type: "tool_use", // Add required 'type' property
name: "new_task", // Correct property name
params: {
mode: "code",
message: "This is already unescaped: \\@file1.txt",
},
partial: false,
}
await newTaskTool(
mockCline as any,
block,
mockAskApproval, // Now correctly typed
mockHandleError,
mockPushToolResult,
mockRemoveClosingTag,
)
expect(mockInitClineWithTask).toHaveBeenCalledWith(
"This is already unescaped: \\@file1.txt", // Expected: \@ remains \@
undefined,
mockCline,
)
})
it("should not un-escape non-escaped @", async () => {
const block: ToolUse = {
type: "tool_use", // Add required 'type' property
name: "new_task", // Correct property name
params: {
mode: "code",
message: "A normal mention @file1.txt",
},
partial: false,
}
await newTaskTool(
mockCline as any,
block,
mockAskApproval, // Now correctly typed
mockHandleError,
mockPushToolResult,
mockRemoveClosingTag,
)
expect(mockInitClineWithTask).toHaveBeenCalledWith(
"A normal mention @file1.txt", // Expected: @ remains @
undefined,
mockCline,
)
})
it("should handle mixed escaping scenarios", async () => {
const block: ToolUse = {
type: "tool_use", // Add required 'type' property
name: "new_task", // Correct property name
params: {
mode: "code",
message: "Mix: @file0.txt, \\@file1.txt, \\\\@file2.txt, \\\\\\\\@file3.txt",
},
partial: false,
}
await newTaskTool(
mockCline as any,
block,
mockAskApproval, // Now correctly typed
mockHandleError,
mockPushToolResult,
mockRemoveClosingTag,
)
expect(mockInitClineWithTask).toHaveBeenCalledWith(
"Mix: @file0.txt, \\@file1.txt, \\@file2.txt, \\\\\\@file3.txt", // Unit Test Expectation: @->@, \@->\@, \\@->\@, \\\\@->\\\\@
undefined,
mockCline,
)
})
// Add more tests for error handling (missing params, invalid mode, approval denied) if needed
})

View file

@ -42,6 +42,9 @@ export async function newTaskTool(
}
cline.consecutiveMistakeCount = 0
// Un-escape one level of backslashes before '@' for hierarchical subtasks
// Un-escape one level: \\@ -> \@ (removes one backslash for hierarchical subtasks)
const unescapedMessage = message.replace(/\\\\@/g, "\\@")
// Verify the mode exists
const targetMode = getModeBySlug(mode, (await cline.providerRef.deref()?.getState())?.customModes)
@ -82,10 +85,10 @@ export async function newTaskTool(
// Delay to allow mode change to take effect before next tool is executed.
await delay(500)
const newCline = await provider.initClineWithTask(message, undefined, cline)
const newCline = await provider.initClineWithTask(unescapedMessage, undefined, cline)
cline.emit("taskSpawned", newCline.taskId)
pushToolResult(`Successfully created new task in ${targetMode.name} mode with message: ${message}`)
pushToolResult(`Successfully created new task in ${targetMode.name} mode with message: ${unescapedMessage}`)
// Set the isPaused flag to true so the parent
// task can wait for the sub-task to finish.

View file

@ -41,8 +41,15 @@ describe("mentionRegex and mentionRegexGlobal", () => {
{ input: "mention@", expected: null }, // Trailing @
{ input: "@/path/trailing\\", expected: null }, // Trailing backslash (invalid escape)
{ input: "@/path/to/file\\not-a-space", expected: null }, // Backslash not followed by space
// Escaped mentions (should not match due to negative lookbehind)
{ input: "This is not a mention: \\@/path/to/file.txt", expected: null },
{ input: "Escaped \\@problems word", expected: null },
{ input: "Text with \\@https://example.com", expected: null },
{ input: "Another \\@a1b2c3d hash", expected: null },
{ input: "Not escaped @terminal", expected: ["@terminal"] }, // Ensure non-escaped still works nearby
{ input: "Double escape \\\\@/should/match", expected: null }, // Double backslash escapes the backslash, currently incorrectly fails to match
{ input: "Text with \\@/escaped/path\\ with\\ spaces.txt", expected: null }, // Escaped mention with escaped spaces within the path part
]
testCases.forEach(({ input, expected }) => {
it(`should handle input: "${input}"`, () => {
// Test mentionRegex (first match)

View file

@ -54,7 +54,7 @@ Mention regex:
*/
export const mentionRegex =
/@((?:\/|\w+:\/\/)(?:[^\s\\]|\\ )+?|[a-f0-9]{7,40}\b|problems\b|git-changes\b|terminal\b)(?=[.,;:!?]?(?=[\s\r\n]|$))/
/(?<!\\)@((?:\/|\w+:\/\/)(?:[^\s\\]|\\ )+?|[a-f0-9]{7,40}\b|problems\b|git-changes\b|terminal\b)(?=[.,;:!?]?(?=[\s\r\n]|$))/
export const mentionRegexGlobal = new RegExp(mentionRegex.source, "g")
export interface MentionSuggestion {