Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions src/core/mentions/__tests__/resolveImageMentions.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import * as path from "path"

import { resolveImageMentions } from "../resolveImageMentions"

vi.mock("fs/promises", () => {
return {
default: {
readFile: vi.fn(),
},
readFile: vi.fn(),
}
})

import * as fs from "fs/promises"

const mockReadFile = vi.mocked(fs.readFile)

describe("resolveImageMentions", () => {
beforeEach(() => {
vi.clearAllMocks()
})

it("should append a data URL when a local png mention is present", async () => {
mockReadFile.mockResolvedValue(Buffer.from("png-bytes"))

const result = await resolveImageMentions({
text: "Please look at @/assets/cat.png",
images: [],
cwd: "/workspace",
})

expect(mockReadFile).toHaveBeenCalledWith(path.resolve("/workspace", "assets/cat.png"))
expect(result.text).toBe("Please look at @/assets/cat.png")
expect(result.images).toEqual([`data:image/png;base64,${Buffer.from("png-bytes").toString("base64")}`])
})

it("should ignore non-image mentions", async () => {
const result = await resolveImageMentions({
text: "See @/src/index.ts",
images: [],
cwd: "/workspace",
})

expect(mockReadFile).not.toHaveBeenCalled()
expect(result.images).toEqual([])
})

it("should skip unreadable files (fail-soft)", async () => {
mockReadFile.mockRejectedValue(new Error("ENOENT"))

const result = await resolveImageMentions({
text: "See @/missing.webp",
images: [],
cwd: "/workspace",
})

expect(result.images).toEqual([])
})

it("should respect rooIgnoreController", async () => {
mockReadFile.mockResolvedValue(Buffer.from("jpg-bytes"))
const rooIgnoreController = {
validateAccess: vi.fn().mockReturnValue(false),
}

const result = await resolveImageMentions({
text: "See @/secret.jpg",
images: [],
cwd: "/workspace",
rooIgnoreController,
})

expect(rooIgnoreController.validateAccess).toHaveBeenCalledWith("secret.jpg")
expect(mockReadFile).not.toHaveBeenCalled()
expect(result.images).toEqual([])
})

it("should dedupe when mention repeats", async () => {
mockReadFile.mockResolvedValue(Buffer.from("png-bytes"))

const result = await resolveImageMentions({
text: "@/a.png and again @/a.png",
images: [],
cwd: "/workspace",
})

expect(result.images).toHaveLength(1)
})
})
8 changes: 7 additions & 1 deletion src/core/mentions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,13 @@ async function getFileOrFolderContent(
const stats = await fs.stat(absPath)

if (stats.isFile()) {
if (rooIgnoreController && !rooIgnoreController.validateAccess(absPath)) {
// Avoid trying to include image binary content as text context.
// Image mentions are handled separately via image attachment flow.
const isBinary = await isBinaryFile(absPath).catch(() => false)
if (isBinary) {
return `(Binary file ${mentionPath} omitted)`
}
if (rooIgnoreController && !rooIgnoreController.validateAccess(unescapedPath)) {
return `(File ${mentionPath} is ignored by .rooignore)`
}
try {
Expand Down
117 changes: 117 additions & 0 deletions src/core/mentions/resolveImageMentions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import * as path from "path"
import * as fs from "fs/promises"

import { mentionRegexGlobal, unescapeSpaces } from "../../shared/context-mentions"

const MAX_IMAGES_PER_MESSAGE = 20

const SUPPORTED_IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".webp"])

function getMimeTypeFromExtension(extLower: string): string | undefined {
if (extLower === ".png") return "image/png"
if (extLower === ".jpg" || extLower === ".jpeg") return "image/jpeg"
if (extLower === ".webp") return "image/webp"
return undefined
}

export interface ResolveImageMentionsOptions {
text: string
images?: string[]
cwd: string
rooIgnoreController?: { validateAccess: (filePath: string) => boolean }
}

export interface ResolveImageMentionsResult {
text: string
images: string[]
}

function isPathWithinCwd(absPath: string, cwd: string): boolean {
const rel = path.relative(cwd, absPath)
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel)
}

function dedupePreserveOrder(values: string[]): string[] {
const seen = new Set<string>()
const result: string[] = []
for (const v of values) {
if (seen.has(v)) continue
seen.add(v)
result.push(v)
}
return result
}

/**
* Resolves local image file mentions like `@/path/to/image.png` found in `text` into `data:image/...;base64,...`
* and appends them to the outgoing `images` array.
*
* - Only supports local workspace-relative mentions (must start with `/`).
* - Only supports: png, jpg, jpeg, webp.
* - Leaves `text` unchanged.
* - Respects `.rooignore` via `rooIgnoreController.validateAccess` when provided.
*/
export async function resolveImageMentions({
text,
images,
cwd,
rooIgnoreController,
}: ResolveImageMentionsOptions): Promise<ResolveImageMentionsResult> {
const existingImages = Array.isArray(images) ? images : []
if (existingImages.length >= MAX_IMAGES_PER_MESSAGE) {
return { text, images: existingImages.slice(0, MAX_IMAGES_PER_MESSAGE) }
}

const mentions = Array.from(text.matchAll(mentionRegexGlobal))
.map((m) => m[1])
.filter(Boolean)
if (mentions.length === 0) {
return { text, images: existingImages }
}

const imageMentions = mentions.filter((mention) => {
if (!mention.startsWith("/")) return false
const relPath = unescapeSpaces(mention.slice(1))
const ext = path.extname(relPath).toLowerCase()
return SUPPORTED_IMAGE_EXTENSIONS.has(ext)
})

if (imageMentions.length === 0) {
return { text, images: existingImages }
}

const newImages: string[] = []
for (const mention of imageMentions) {
if (existingImages.length + newImages.length >= MAX_IMAGES_PER_MESSAGE) {
break
}

const relPath = unescapeSpaces(mention.slice(1))
const absPath = path.resolve(cwd, relPath)
if (!isPathWithinCwd(absPath, cwd)) {
continue
}

if (rooIgnoreController && !rooIgnoreController.validateAccess(relPath)) {
continue
}

const ext = path.extname(relPath).toLowerCase()
const mimeType = getMimeTypeFromExtension(ext)
if (!mimeType) {
continue
}

try {
const buffer = await fs.readFile(absPath)
Comment thread
hannesrudolph marked this conversation as resolved.
Outdated
const base64 = buffer.toString("base64")
newImages.push(`data:${mimeType};base64,${base64}`)
} catch {
// Fail-soft: skip unreadable/missing files.
continue
}
}

const merged = dedupePreserveOrder([...existingImages, ...newImages]).slice(0, MAX_IMAGES_PER_MESSAGE)
return { text, images: merged }
}
6 changes: 3 additions & 3 deletions src/core/webview/__tests__/ClineProvider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3048,7 +3048,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => {
expect(mockCline.overwriteClineMessages).toHaveBeenCalledWith([mockMessages[0]])
expect(mockCline.overwriteApiConversationHistory).toHaveBeenCalledWith([{ ts: 1000 }])
// Verify submitUserMessage was called with the edited content
expect(mockCline.submitUserMessage).toHaveBeenCalledWith("Edited message with preserved images", undefined)
expect(mockCline.submitUserMessage).toHaveBeenCalledWith("Edited message with preserved images", [])
})

test("handles editing messages with file attachments", async () => {
Expand Down Expand Up @@ -3101,7 +3101,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => {
})

expect(mockCline.overwriteClineMessages).toHaveBeenCalled()
expect(mockCline.submitUserMessage).toHaveBeenCalledWith("Edited message with file attachment", undefined)
expect(mockCline.submitUserMessage).toHaveBeenCalledWith("Edited message with file attachment", [])
})
})

Expand Down Expand Up @@ -3632,7 +3632,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => {
await messageHandler({ type: "editMessageConfirm", messageTs: 2000, text: largeEditedContent })

expect(mockCline.overwriteClineMessages).toHaveBeenCalled()
expect(mockCline.submitUserMessage).toHaveBeenCalledWith(largeEditedContent, undefined)
expect(mockCline.submitUserMessage).toHaveBeenCalledWith(largeEditedContent, [])
})

test("handles deleting messages with large payloads", async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ describe("webviewMessageHandler - checkpoint operations", () => {
operation: "edit",
editData: {
editedContent: "Edited checkpoint message",
images: undefined,
images: [],
apiConversationHistoryIndex: 0,
},
})
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import * as fs from "fs/promises"
import * as path from "path"

// Must mock dependencies before importing the handler module.
vi.mock("../../../api/providers/fetchers/modelCache")

import { webviewMessageHandler } from "../webviewMessageHandler"
import type { ClineProvider } from "../ClineProvider"

vi.mock("vscode", () => ({
window: {
showInformationMessage: vi.fn(),
showErrorMessage: vi.fn(),
},
workspace: {
workspaceFolders: [{ uri: { fsPath: "/mock/workspace" } }],
},
}))

describe("webviewMessageHandler - image mentions (integration)", () => {
it("resolves image mentions for newTask and passes images to createTask", async () => {
const tmpRoot = await fs.mkdtemp(path.join(process.cwd(), "tmp-image-mentions-"))
try {
const imgBytes = Buffer.from("png-bytes")
await fs.writeFile(path.join(tmpRoot, "cat.png"), imgBytes)

const mockProvider = {
cwd: tmpRoot,
getCurrentTask: vi.fn().mockReturnValue(undefined),
createTask: vi.fn().mockResolvedValue(undefined),
postMessageToWebview: vi.fn().mockResolvedValue(undefined),
} as unknown as ClineProvider

await webviewMessageHandler(mockProvider, {
type: "newTask",
text: "Please look at @/cat.png",
images: [],
} as any)

expect(mockProvider.createTask).toHaveBeenCalledWith("Please look at @/cat.png", [
`data:image/png;base64,${imgBytes.toString("base64")}`,
])
} finally {
await fs.rm(tmpRoot, { recursive: true, force: true })
}
})

it("resolves image mentions for askResponse and passes images to handleWebviewAskResponse", async () => {
const tmpRoot = await fs.mkdtemp(path.join(process.cwd(), "tmp-image-mentions-"))
try {
const imgBytes = Buffer.from("jpg-bytes")
await fs.writeFile(path.join(tmpRoot, "cat.jpg"), imgBytes)

const handleWebviewAskResponse = vi.fn()
const mockProvider = {
cwd: tmpRoot,
getCurrentTask: vi.fn().mockReturnValue({
cwd: tmpRoot,
handleWebviewAskResponse,
}),
} as unknown as ClineProvider

await webviewMessageHandler(mockProvider, {
type: "askResponse",
askResponse: "messageResponse",
text: "Please look at @/cat.jpg",
images: [],
} as any)

expect(handleWebviewAskResponse).toHaveBeenCalledWith("messageResponse", "Please look at @/cat.jpg", [
`data:image/jpeg;base64,${imgBytes.toString("base64")}`,
])
} finally {
await fs.rm(tmpRoot, { recursive: true, force: true })
}
})
})
36 changes: 36 additions & 0 deletions src/core/webview/__tests__/webviewMessageHandler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,15 @@ vi.mock("../../../utils/fs")
vi.mock("../../../utils/path")
vi.mock("../../../utils/globalContext")

vi.mock("../../mentions/resolveImageMentions", () => ({
resolveImageMentions: vi.fn(async ({ text, images }: { text: string; images?: string[] }) => ({
text,
images: [...(images ?? []), "data:image/png;base64,from-mention"],
})),
}))

import { resolveImageMentions } from "../../mentions/resolveImageMentions"

describe("webviewMessageHandler - requestLmStudioModels", () => {
beforeEach(() => {
vi.clearAllMocks()
Expand Down Expand Up @@ -138,6 +147,33 @@ describe("webviewMessageHandler - requestLmStudioModels", () => {
})
})

describe("webviewMessageHandler - image mentions", () => {
beforeEach(() => {
vi.clearAllMocks()
})

it("should resolve image mentions for askResponse payloads", async () => {
const mockHandleWebviewAskResponse = vi.fn()
vi.mocked(mockClineProvider.getCurrentTask).mockReturnValue({
cwd: "/mock/workspace",
rooIgnoreController: undefined,
handleWebviewAskResponse: mockHandleWebviewAskResponse,
} as any)

await webviewMessageHandler(mockClineProvider, {
type: "askResponse",
askResponse: "messageResponse",
text: "See @/img.png",
images: [],
})

expect(vi.mocked(resolveImageMentions)).toHaveBeenCalled()
expect(mockHandleWebviewAskResponse).toHaveBeenCalledWith("messageResponse", "See @/img.png", [
"data:image/png;base64,from-mention",
])
})
})

describe("webviewMessageHandler - requestOllamaModels", () => {
beforeEach(() => {
vi.clearAllMocks()
Expand Down
Loading
Loading